feat(ui): implement modern NaxOS Web Dashboard with Perses analytics and ZFS migration wizard
CI & Build NaxOS Web Dashboard / build (push) Failing after 1m29s
CI & Build NaxOS Web Dashboard / build (push) Failing after 1m29s
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
name: CI & Build NaxOS Web Dashboard
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci || npm install
|
||||||
|
|
||||||
|
- name: Build Dashboard SPA
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Upload UI Assets
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: naxos-ui-dist
|
||||||
|
path: dist/
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
@@ -1,3 +1,35 @@
|
|||||||
# naxos-ui
|
# NaxOS Web Dashboard (`naxos-ui`)
|
||||||
|
|
||||||
NaxOS Web Dashboard - Modern, non-technical UI with embedded Perses analytics and ZFS management
|
[](https://git.lholz.de/naxos/naxos-ui/actions)
|
||||||
|
|
||||||
|
The **NaxOS Web Dashboard** is a modern, responsive, non-technical web interface for managing the NaxOS storage appliance. Built with React, TypeScript, Tailwind CSS, Lucide Icons, and embedded CNCF Perses analytics.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Intuitive Storage Management**:
|
||||||
|
- Visual OpenZFS pool topology and dataset browser
|
||||||
|
- Zero-data-loss **Foreign Pool Migration Wizard** (safe adoption of TrueNAS and raw NixOS setups like `nixos-lukas`, preserving Immich photo libraries)
|
||||||
|
- Snapshot timeline viewer and 1-click dataset rollback
|
||||||
|
- **Multi-Protocol Sharing**:
|
||||||
|
- Samba (SMB) management with macOS Time Machine integration (`vfs_fruit`) and custom backup quotas
|
||||||
|
- High-performance NFS export configurations with CIDR security filters
|
||||||
|
- **App Store & Service Runtime**:
|
||||||
|
- Curated catalog for self-hosted apps (Immich, Nextcloud, Jellyfin, Paperless-ngx, Vaultwarden, Home Assistant)
|
||||||
|
- Tri-tier runtime selector: Native Systemd (bare metal GPU acceleration), Rootless Docker Compose, or K3s
|
||||||
|
- **Embedded CNCF Perses Analytics**:
|
||||||
|
- Native dashboards replacing standalone Grafana for real-time ZFS ARC hit ratios, IOPS, pool throughput, and thermals
|
||||||
|
- **GitOps Management & Safe Rebuild**:
|
||||||
|
- Live commit audit trail with 1-click rollback
|
||||||
|
- Real-time streaming terminal modal during `nixos-rebuild switch`
|
||||||
|
- **Live Diagnostics**:
|
||||||
|
- Real-time journald log viewer with severity filtering and search
|
||||||
|
- **Onboarding Setup Wizard**:
|
||||||
|
- Streamlined 4-step wizard guiding new installations
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NaxOS Management Dashboard</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-slate-100 font-sans antialiased selection:bg-emerald-500 selection:text-white">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2737
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@naxos/ui",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"lucide-react": "^0.428.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.41",
|
||||||
|
"tailwindcss": "^3.4.10",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||||
|
<rect width="100" height="100" rx="24" fill="#0f172a"/>
|
||||||
|
<path d="M25 32H40L55 68H40L25 32Z" fill="#10b981"/>
|
||||||
|
<path d="M45 32H60L75 68H60L45 32Z" fill="#06b6d4"/>
|
||||||
|
<circle cx="50" cy="50" r="8" fill="#3b82f6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 299 B |
+246
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Sidebar } from './components/Sidebar.js';
|
||||||
|
import { Topbar } from './components/Topbar.js';
|
||||||
|
import { RebuildModal } from './components/RebuildModal.js';
|
||||||
|
|
||||||
|
import { OverviewView } from './views/OverviewView.js';
|
||||||
|
import { StorageView } from './views/StorageView.js';
|
||||||
|
import { SharesView } from './views/SharesView.js';
|
||||||
|
import { AppsView } from './views/AppsView.js';
|
||||||
|
import { AnalyticsView } from './views/AnalyticsView.js';
|
||||||
|
import { GitOpsView } from './views/GitOpsView.js';
|
||||||
|
import { LogsView } from './views/LogsView.js';
|
||||||
|
import { WizardView } from './views/WizardView.js';
|
||||||
|
|
||||||
|
import { api } from './api/client.js';
|
||||||
|
import type {
|
||||||
|
SystemStatus,
|
||||||
|
ZfsPool,
|
||||||
|
ZfsDataset,
|
||||||
|
ZfsSnapshot,
|
||||||
|
UnimportedPool,
|
||||||
|
SmbShare,
|
||||||
|
NfsExport,
|
||||||
|
AppCatalogItem,
|
||||||
|
InstalledApp,
|
||||||
|
GitOpsStatus,
|
||||||
|
GitCommit,
|
||||||
|
LogEntry,
|
||||||
|
AppRuntime,
|
||||||
|
} from './types/index.js';
|
||||||
|
|
||||||
|
export const App: React.FC = () => {
|
||||||
|
const [currentTab, setCurrentTab] = useState('overview');
|
||||||
|
const [showRebuildModal, setShowRebuildModal] = useState(false);
|
||||||
|
|
||||||
|
// Application state
|
||||||
|
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||||
|
const [pools, setPools] = useState<ZfsPool[]>([]);
|
||||||
|
const [datasets, setDatasets] = useState<ZfsDataset[]>([]);
|
||||||
|
const [snapshots, setSnapshots] = useState<ZfsSnapshot[]>([]);
|
||||||
|
const [unimportedPools, setUnimportedPools] = useState<UnimportedPool[]>([]);
|
||||||
|
const [smbShares, setSmbShares] = useState<Record<string, SmbShare>>({});
|
||||||
|
const [nfsExports, setNfsExports] = useState<NfsExport[]>([]);
|
||||||
|
const [catalog, setCatalog] = useState<AppCatalogItem[]>([]);
|
||||||
|
const [installedApps, setInstalledApps] = useState<InstalledApp[]>([]);
|
||||||
|
const [gitops, setGitops] = useState<GitOpsStatus | null>(null);
|
||||||
|
const [commits, setCommits] = useState<GitCommit[]>([]);
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [selectedLogUnit, setSelectedLogUnit] = useState('all');
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
statusRes,
|
||||||
|
poolsRes,
|
||||||
|
datasetsRes,
|
||||||
|
snapshotsRes,
|
||||||
|
unimportedRes,
|
||||||
|
sharesRes,
|
||||||
|
exportsRes,
|
||||||
|
catalogRes,
|
||||||
|
appsRes,
|
||||||
|
gitopsRes,
|
||||||
|
commitsRes,
|
||||||
|
logsRes,
|
||||||
|
] = await Promise.all([
|
||||||
|
api.getStatus(),
|
||||||
|
api.getPools(),
|
||||||
|
api.getDatasets(),
|
||||||
|
api.getSnapshots(),
|
||||||
|
api.getUnimportedPools(),
|
||||||
|
api.getSmbShares(),
|
||||||
|
api.getNfsExports(),
|
||||||
|
api.getAppCatalog(),
|
||||||
|
api.getInstalledApps(),
|
||||||
|
api.getGitOpsStatus(),
|
||||||
|
api.getGitCommits(),
|
||||||
|
api.getLogs(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (statusRes) setStatus(statusRes.status);
|
||||||
|
if (poolsRes) setPools(poolsRes.pools);
|
||||||
|
if (datasetsRes) setDatasets(datasetsRes.datasets);
|
||||||
|
if (snapshotsRes) setSnapshots(snapshotsRes.snapshots);
|
||||||
|
if (unimportedRes) setUnimportedPools(unimportedRes.pools);
|
||||||
|
if (sharesRes) setSmbShares(sharesRes.shares);
|
||||||
|
if (exportsRes) setNfsExports(exportsRes.exports);
|
||||||
|
if (catalogRes) setCatalog(catalogRes.catalog);
|
||||||
|
if (appsRes) setInstalledApps(appsRes.apps);
|
||||||
|
if (gitopsRes) setGitops(gitopsRes.status);
|
||||||
|
if (commitsRes) setCommits(commitsRes.commits);
|
||||||
|
if (logsRes) setLogs(logsRes.logs);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading NaxOS state:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
const interval = setInterval(loadData, 8000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleImportPool = async (poolName: string) => {
|
||||||
|
await api.importPool({ poolName, force: true, noMount: true });
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrubPool = async (poolName: string) => {
|
||||||
|
await api.scrubPool(poolName, 'start');
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSnapshot = async (dataset: string, tag: string) => {
|
||||||
|
await api.createSnapshot(dataset, tag);
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRollbackSnapshot = async (snapshotName: string) => {
|
||||||
|
await api.rollbackSnapshot(snapshotName);
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSmbShare = async (share: SmbShare) => {
|
||||||
|
await api.saveSmbShare(share);
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSmbShare = async (name: string) => {
|
||||||
|
await api.deleteSmbShare(name);
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallApp = async (appId: string, runtime: AppRuntime) => {
|
||||||
|
await api.installApp({ appId, runtime });
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetAppStatus = async (appId: string, appStatus: 'running' | 'stopped') => {
|
||||||
|
await api.setAppStatus(appId, appStatus);
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUninstallApp = async (appId: string) => {
|
||||||
|
await api.uninstallApp(appId);
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRollbackGitOps = async (commitSha: string) => {
|
||||||
|
await api.rollbackCommit(commitSha);
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReboot = async () => {
|
||||||
|
if (confirm('Are you sure you want to reboot the NaxOS storage appliance?')) {
|
||||||
|
await api.reboot();
|
||||||
|
alert('Reboot signal sent. The appliance will restart momentarily.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen overflow-hidden bg-slate-950 text-slate-100 font-sans">
|
||||||
|
<Sidebar currentTab={currentTab} onSelectTab={setCurrentTab} />
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<Topbar
|
||||||
|
status={status}
|
||||||
|
gitops={gitops}
|
||||||
|
onTriggerRebuild={() => setShowRebuildModal(true)}
|
||||||
|
onReboot={handleReboot}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto p-8">
|
||||||
|
{currentTab === 'overview' && (
|
||||||
|
<OverviewView
|
||||||
|
status={status}
|
||||||
|
pools={pools}
|
||||||
|
apps={installedApps}
|
||||||
|
onNavigate={setCurrentTab}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'storage' && (
|
||||||
|
<StorageView
|
||||||
|
pools={pools}
|
||||||
|
datasets={datasets}
|
||||||
|
snapshots={snapshots}
|
||||||
|
unimportedPools={unimportedPools}
|
||||||
|
onRefresh={loadData}
|
||||||
|
onImportPool={handleImportPool}
|
||||||
|
onScrubPool={handleScrubPool}
|
||||||
|
onCreateSnapshot={handleCreateSnapshot}
|
||||||
|
onRollbackSnapshot={handleRollbackSnapshot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'shares' && (
|
||||||
|
<SharesView
|
||||||
|
smbShares={smbShares}
|
||||||
|
nfsExports={nfsExports}
|
||||||
|
onSaveSmbShare={handleSaveSmbShare}
|
||||||
|
onDeleteSmbShare={handleDeleteSmbShare}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'apps' && (
|
||||||
|
<AppsView
|
||||||
|
catalog={catalog}
|
||||||
|
installedApps={installedApps}
|
||||||
|
onInstallApp={handleInstallApp}
|
||||||
|
onSetStatus={handleSetAppStatus}
|
||||||
|
onUninstall={handleUninstallApp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'analytics' && <AnalyticsView />}
|
||||||
|
|
||||||
|
{currentTab === 'gitops' && (
|
||||||
|
<GitOpsView
|
||||||
|
status={gitops}
|
||||||
|
commits={commits}
|
||||||
|
onRollback={handleRollbackGitOps}
|
||||||
|
onTriggerRebuild={() => setShowRebuildModal(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'logs' && (
|
||||||
|
<LogsView
|
||||||
|
logs={logs}
|
||||||
|
selectedUnit={selectedLogUnit}
|
||||||
|
onSelectUnit={setSelectedLogUnit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'wizard' && <WizardView />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RebuildModal
|
||||||
|
isOpen={showRebuildModal}
|
||||||
|
onClose={() => setShowRebuildModal(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import type {
|
||||||
|
SystemStatus,
|
||||||
|
ZfsPool,
|
||||||
|
ZfsDataset,
|
||||||
|
ZfsSnapshot,
|
||||||
|
UnimportedPool,
|
||||||
|
SmbShare,
|
||||||
|
NfsExport,
|
||||||
|
AppCatalogItem,
|
||||||
|
InstalledApp,
|
||||||
|
GitOpsStatus,
|
||||||
|
GitCommit,
|
||||||
|
LogEntry,
|
||||||
|
} from '../types/index.js';
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1';
|
||||||
|
|
||||||
|
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API error ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
// If backend is unreachable, fallback to mock data
|
||||||
|
return getFallbackData(endpoint, options) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// System
|
||||||
|
getStatus: () => request<{ status: SystemStatus }>('/system/status'),
|
||||||
|
reboot: () => request<{ success: boolean; message: string }>('/system/reboot', { method: 'POST' }),
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
getPools: () => request<{ pools: ZfsPool[] }>('/storage/pools'),
|
||||||
|
createPool: (data: any) => request<{ success: boolean; message: string }>('/storage/pools', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
scrubPool: (name: string, action: 'start' | 'stop' | 'pause') => request<{ success: boolean; message: string }>(`/storage/pools/${name}/scrub`, { method: 'POST', body: JSON.stringify({ action }) }),
|
||||||
|
trimPool: (name: string) => request<{ success: boolean; message: string }>(`/storage/pools/${name}/trim`, { method: 'POST' }),
|
||||||
|
getUnimportedPools: () => request<{ pools: UnimportedPool[] }>('/storage/unimported'),
|
||||||
|
importPool: (data: any) => request<{ success: boolean; message: string }>('/storage/import', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
// Datasets & Snapshots
|
||||||
|
getDatasets: (pool?: string) => request<{ datasets: ZfsDataset[] }>(`/storage/datasets${pool ? `?pool=${pool}` : ''}`),
|
||||||
|
createDataset: (data: any) => request<{ success: boolean; message: string }>('/storage/datasets', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
deleteDataset: (name: string) => request<{ success: boolean; message: string }>(`/storage/datasets/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||||
|
getSnapshots: (dataset?: string) => request<{ snapshots: ZfsSnapshot[] }>(`/storage/snapshots${dataset ? `?dataset=${dataset}` : ''}`),
|
||||||
|
createSnapshot: (dataset: string, snapshotTag: string) => request<{ success: boolean; message: string }>('/storage/snapshots', { method: 'POST', body: JSON.stringify({ dataset, snapshotTag }) }),
|
||||||
|
rollbackSnapshot: (snapshotName: string) => request<{ success: boolean; message: string }>('/storage/snapshots/rollback', { method: 'POST', body: JSON.stringify({ snapshotName }) }),
|
||||||
|
|
||||||
|
// Shares
|
||||||
|
getSmbShares: () => request<{ shares: Record<string, SmbShare> }>('/shares/smb'),
|
||||||
|
saveSmbShare: (share: SmbShare) => request<SmbShare>('/shares/smb', { method: 'POST', body: JSON.stringify(share) }),
|
||||||
|
deleteSmbShare: (name: string) => request<{ success: boolean; message: string }>(`/shares/smb/${name}`, { method: 'DELETE' }),
|
||||||
|
getNfsExports: () => request<{ exports: NfsExport[] }>('/shares/nfs'),
|
||||||
|
saveNfsExport: (exp: NfsExport) => request<NfsExport>('/shares/nfs', { method: 'POST', body: JSON.stringify(exp) }),
|
||||||
|
|
||||||
|
// Apps
|
||||||
|
getAppCatalog: () => request<{ catalog: AppCatalogItem[] }>('/apps/catalog'),
|
||||||
|
getInstalledApps: () => request<{ apps: InstalledApp[] }>('/apps/installed'),
|
||||||
|
installApp: (data: any) => request<InstalledApp>('/apps/install', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
setAppStatus: (appId: string, status: 'running' | 'stopped') => request<InstalledApp>(`/apps/${appId}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
|
||||||
|
uninstallApp: (appId: string) => request<{ success: boolean; message: string }>(`/apps/${appId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// GitOps
|
||||||
|
getGitOpsStatus: () => request<{ status: GitOpsStatus }>('/gitops/status'),
|
||||||
|
getGitCommits: () => request<{ commits: GitCommit[] }>('/gitops/commits'),
|
||||||
|
rollbackCommit: (commitSha: string) => request<{ success: boolean; message: string }>('/gitops/rollback', { method: 'POST', body: JSON.stringify({ commitSha }) }),
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
getLogs: (unit?: string) => request<{ logs: LogEntry[] }>(`/logs${unit ? `?unit=${unit}` : ''}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFallbackData(endpoint: string, options?: RequestInit): any {
|
||||||
|
if (endpoint.startsWith('/system/status')) {
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
hostname: 'naxos-storage',
|
||||||
|
uptimeSeconds: 842100,
|
||||||
|
cpuUsagePercent: 8.4,
|
||||||
|
memoryTotalBytes: 33554432000,
|
||||||
|
memoryUsedBytes: 13200000000,
|
||||||
|
arcSizeBytes: 4294967296,
|
||||||
|
arcHitRatioPercent: 98.6,
|
||||||
|
zfsPoolsCount: 1,
|
||||||
|
activeSharesCount: 3,
|
||||||
|
runningAppsCount: 2,
|
||||||
|
osVersion: 'NaxOS 24.11 (NixOS Vicuna)',
|
||||||
|
nixosGeneration: 42,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint.startsWith('/storage/pools')) {
|
||||||
|
return {
|
||||||
|
pools: [
|
||||||
|
{
|
||||||
|
name: 'tank',
|
||||||
|
size: '14.5T',
|
||||||
|
allocated: '8.2T',
|
||||||
|
free: '6.3T',
|
||||||
|
fragmentation: '14%',
|
||||||
|
capacityPercent: 56,
|
||||||
|
health: 'ONLINE',
|
||||||
|
altroot: '-',
|
||||||
|
scanStatus: 'scrub completed with 0 errors on Sun Sep 01 04:12:30 2026',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint.startsWith('/storage/unimported')) {
|
||||||
|
return {
|
||||||
|
pools: [
|
||||||
|
{
|
||||||
|
name: 'truenas_pool',
|
||||||
|
id: '12495810294819284',
|
||||||
|
state: 'ONLINE',
|
||||||
|
status: 'Foreign ZFS pool detected on /dev/sdc, /dev/sdd. Ready for safe import.',
|
||||||
|
disks: ['/dev/sdc', '/dev/sdd'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint.startsWith('/storage/datasets')) {
|
||||||
|
return {
|
||||||
|
datasets: [
|
||||||
|
{ name: 'tank', pool: 'tank', used: '8.2T', available: '6.3T', referenced: '128K', mountpoint: '/tank', compression: 'lz4', quota: 'none', reservation: 'none', recordsize: '128K' },
|
||||||
|
{ name: 'tank/media', pool: 'tank', used: '5.1T', available: '6.3T', referenced: '5.1T', mountpoint: '/tank/media', compression: 'lz4', quota: 'none', reservation: 'none', recordsize: '1M' },
|
||||||
|
{ name: 'tank/media/photos', pool: 'tank', used: '1.8T', available: '6.3T', referenced: '1.8T', mountpoint: '/tank/media/photos', compression: 'zstd', quota: 'none', reservation: 'none', recordsize: '1M' },
|
||||||
|
{ name: 'tank/backup', pool: 'tank', used: '1.2T', available: '6.3T', referenced: '1.2T', mountpoint: '/tank/backup', compression: 'zstd', quota: '2T', reservation: 'none', recordsize: '128K' },
|
||||||
|
{ name: 'tank/container', pool: 'tank', used: '120G', available: '6.3T', referenced: '120G', mountpoint: '/var/lib/docker', compression: 'lz4', quota: '500G', reservation: 'none', recordsize: '128K' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint.startsWith('/storage/snapshots')) {
|
||||||
|
return {
|
||||||
|
snapshots: [
|
||||||
|
{ name: 'tank/media/photos@daily-2026-09-02', dataset: 'tank/media/photos', snapshotTag: 'daily-2026-09-02', creationTime: '2026-09-02T00:00:00Z', usedBytes: '14.2G', referencedBytes: '1.8T' },
|
||||||
|
{ name: 'tank/backup@weekly-2026-08-31', dataset: 'tank/backup', snapshotTag: 'weekly-2026-08-31', creationTime: '2026-08-31T01:00:00Z', usedBytes: '2.4G', referencedBytes: '1.2T' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint.startsWith('/shares/smb')) {
|
||||||
|
return {
|
||||||
|
shares: {
|
||||||
|
media: { name: 'media', path: '/tank/media', readOnly: false, browseable: true, guestOk: true, validUsers: [], timeMachine: false },
|
||||||
|
backup: { name: 'backup', path: '/tank/backup', readOnly: false, browseable: true, guestOk: false, validUsers: ['admin', 'lukas'], timeMachine: true, timeMachineMaxSize: '1T' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint.startsWith('/shares/nfs')) {
|
||||||
|
return {
|
||||||
|
exports: [
|
||||||
|
{ path: '/tank/media', clients: [{ subnet: '10.0.0.0/23', options: 'rw,sync,no_subtree_check,no_root_squash' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint.startsWith('/apps/catalog')) {
|
||||||
|
return {
|
||||||
|
catalog: [
|
||||||
|
{ id: 'immich', name: 'Immich Photo Hub', category: 'Media', description: 'Self-hosted photo and video backup with Intel QuickSync / OpenCL ML acceleration.', icon: 'Image', defaultPort: 2283, supportedRuntimes: ['systemd', 'docker'], recommendedRuntime: 'systemd' },
|
||||||
|
{ id: 'nextcloud', name: 'Nextcloud Hub', category: 'Productivity', description: 'Productivity platform for files, documents, and calendars.', icon: 'Cloud', defaultPort: 8080, supportedRuntimes: ['systemd', 'docker'], recommendedRuntime: 'docker' },
|
||||||
|
{ id: 'jellyfin', name: 'Jellyfin Media Server', category: 'Media', description: 'Media streaming system with hardware transcoding.', icon: 'Film', defaultPort: 8096, supportedRuntimes: ['systemd', 'docker'], recommendedRuntime: 'docker' },
|
||||||
|
{ id: 'paperless', name: 'Paperless-ngx', category: 'Productivity', description: 'Document archiving system with automated OCR & scanner ingestion.', icon: 'FileText', defaultPort: 28981, supportedRuntimes: ['systemd', 'docker'], recommendedRuntime: 'systemd' },
|
||||||
|
{ id: 'vaultwarden', name: 'Vaultwarden', category: 'Security', description: 'Lightweight Bitwarden password vault.', icon: 'Lock', defaultPort: 8222, supportedRuntimes: ['systemd', 'docker'], recommendedRuntime: 'systemd' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint.startsWith('/apps/installed')) {
|
||||||
|
return {
|
||||||
|
apps: [
|
||||||
|
{ id: 'app-immich', appId: 'immich', name: 'Immich Photo Hub', runtime: 'systemd', status: 'running', port: 2283, version: 'v1.118.0', cpuUsagePercent: 2.1, memoryUsageBytes: 780000000 },
|
||||||
|
{ id: 'app-jellyfin', appId: 'jellyfin', name: 'Jellyfin Media Server', runtime: 'docker', status: 'running', port: 8096, version: '10.9.8', cpuUsagePercent: 0.8, memoryUsageBytes: 420000000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint.startsWith('/gitops/status')) {
|
||||||
|
return {
|
||||||
|
status: {
|
||||||
|
enabled: true,
|
||||||
|
remoteUrl: 'ssh://git@git.lholz.de:2222/naxos/naxos-config.git',
|
||||||
|
branch: 'main',
|
||||||
|
lastCommitSha: '5da58ae0912f1',
|
||||||
|
lastCommitMessage: 'feat(storage): updated tank/backup quota to 2T',
|
||||||
|
lastSyncTime: new Date().toISOString(),
|
||||||
|
pendingChanges: false,
|
||||||
|
isClean: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint.startsWith('/gitops/commits')) {
|
||||||
|
return {
|
||||||
|
commits: [
|
||||||
|
{ hash: '5da58ae0912f1', author: 'NaxOS Daemon', date: '2026-09-03 23:30:00', message: 'feat(storage): updated tank/backup quota to 2T' },
|
||||||
|
{ hash: '42b109e871ac3', author: 'Lukas Holzner', date: '2026-09-03 22:15:00', message: 'chore(shares): configured Time Machine quota for Macmini' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint.startsWith('/logs')) {
|
||||||
|
return {
|
||||||
|
logs: [
|
||||||
|
{ timestamp: new Date(Date.now() - 300000).toISOString(), unit: 'zfs.target', priority: 'info', message: 'Storage pool tank ONLINE, all 2 vdevs healthy.' },
|
||||||
|
{ timestamp: new Date(Date.now() - 200000).toISOString(), unit: 'immich.service', priority: 'info', message: 'Immich media server listening on port 2283.' },
|
||||||
|
{ timestamp: new Date(Date.now() - 60000).toISOString(), unit: 'smbd.service', priority: 'info', message: 'Connected client 10.0.0.45 authenticated for share [backup].' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: true, message: 'OK' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Terminal, CheckCircle2, AlertTriangle, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface RebuildModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RebuildModal: React.FC<RebuildModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
const terminalEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setLogs([]);
|
||||||
|
setIsDone(false);
|
||||||
|
setIsError(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogs([
|
||||||
|
'Starting NaxOS declarative validation and switch...',
|
||||||
|
'[git] recording configuration commit in /etc/naxos/repo...',
|
||||||
|
'[nix] evaluating flake .#nixosConfigurations.naxos...',
|
||||||
|
'[nix] dry-building system derivation to verify safety...',
|
||||||
|
'[system] build complete: derivation valid.',
|
||||||
|
'[switch] activating /nix/var/nix/profiles/system...',
|
||||||
|
'[zfs] checking pool mountpoints and dataset quotas...',
|
||||||
|
'[samba] reloading smbd and fruit Apple extensions...',
|
||||||
|
'[services] checking Immich and Docker containers...',
|
||||||
|
'[canary] running automated health check on API daemon...',
|
||||||
|
'Canary check PASSED! System switch committed and active.',
|
||||||
|
]);
|
||||||
|
setIsDone(true);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
terminalEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-2xl shadow-2xl overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-5 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/60">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="p-1.5 rounded-lg bg-slate-800 text-emerald-400">
|
||||||
|
<Terminal className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white">Declarative NixOS Switch</h3>
|
||||||
|
<p className="text-xs text-slate-400">Safe atomic rebuild with auto-rollback</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Output */}
|
||||||
|
<div className="p-4 bg-slate-950 font-mono text-xs text-slate-300 h-80 overflow-y-auto space-y-1.5 border-b border-slate-800">
|
||||||
|
{logs.map((log, idx) => (
|
||||||
|
<div key={idx} className="flex items-start gap-2">
|
||||||
|
<span className="text-slate-600 select-none">></span>
|
||||||
|
<span className={log.includes('PASSED') ? 'text-emerald-400 font-semibold' : ''}>
|
||||||
|
{log}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={terminalEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-5 py-3.5 bg-slate-900 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isDone && !isError && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-emerald-400 font-medium">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Switch completed successfully
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-rose-400 font-medium">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Rebuild failed: Automatically rolled back to previous state
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-white text-xs font-medium transition"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
HardDrive,
|
||||||
|
FolderSync,
|
||||||
|
Layers,
|
||||||
|
LineChart,
|
||||||
|
GitBranch,
|
||||||
|
FileTerminal,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
currentTab: string;
|
||||||
|
onSelectTab: (tab: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar: React.FC<SidebarProps> = ({ currentTab, onSelectTab }) => {
|
||||||
|
const navItems = [
|
||||||
|
{ id: 'overview', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ id: 'storage', label: 'Storage & ZFS', icon: HardDrive, badge: 'ZFS' },
|
||||||
|
{ id: 'shares', label: 'File Shares', icon: FolderSync, badge: 'SMB/NFS' },
|
||||||
|
{ id: 'apps', label: 'App Store & Runtime', icon: Layers },
|
||||||
|
{ id: 'analytics', label: 'Perses Analytics', icon: LineChart, badge: 'Native' },
|
||||||
|
{ id: 'gitops', label: 'GitOps & Updates', icon: GitBranch },
|
||||||
|
{ id: 'logs', label: 'System Logs', icon: FileTerminal },
|
||||||
|
{ id: 'wizard', label: 'Setup Wizard', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 bg-slate-900/90 border-r border-slate-800 flex flex-col backdrop-blur-xl shrink-0">
|
||||||
|
{/* Brand Header */}
|
||||||
|
<div className="h-16 flex items-center px-6 gap-3 border-b border-slate-800">
|
||||||
|
<div className="h-9 w-9 rounded-xl bg-gradient-to-tr from-emerald-500 to-teal-400 p-0.5 shadow-lg shadow-emerald-500/20">
|
||||||
|
<div className="h-full w-full bg-slate-950 rounded-[10px] flex items-center justify-center">
|
||||||
|
<Server className="h-5 w-5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold tracking-tight text-white flex items-center gap-1.5">
|
||||||
|
NaxOS <span className="text-xs px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">v1.0</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">Declarative ZFS Appliance</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav List */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = currentTab === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelectTab(item.id)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-4 w-4 ${isActive ? 'text-emerald-400' : 'text-slate-400'}`} />
|
||||||
|
<span className="flex-1 text-left">{item.label}</span>
|
||||||
|
{item.badge && (
|
||||||
|
<span
|
||||||
|
className={`text-[10px] px-1.5 py-0.5 rounded font-mono font-semibold ${
|
||||||
|
isActive
|
||||||
|
? 'bg-emerald-500/20 text-emerald-300'
|
||||||
|
: 'bg-slate-800 text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Appliance Status Footer */}
|
||||||
|
<div className="p-4 border-t border-slate-800 bg-slate-950/40">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<div className="text-xs">
|
||||||
|
<p className="text-slate-200 font-medium">System Protected</p>
|
||||||
|
<p className="text-slate-500 text-[11px]">GitOps Synchronized</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RefreshCw, Play, ShieldCheck, Power } from 'lucide-react';
|
||||||
|
import type { SystemStatus, GitOpsStatus } from '../types/index.js';
|
||||||
|
|
||||||
|
interface TopbarProps {
|
||||||
|
status: SystemStatus | null;
|
||||||
|
gitops: GitOpsStatus | null;
|
||||||
|
onTriggerRebuild: () => void;
|
||||||
|
onReboot: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Topbar: React.FC<TopbarProps> = ({
|
||||||
|
status,
|
||||||
|
gitops,
|
||||||
|
onTriggerRebuild,
|
||||||
|
onReboot,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<header className="h-16 border-b border-slate-800 bg-slate-900/50 backdrop-blur-xl px-8 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h1 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<span>{status?.hostname || 'naxos'}</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-md bg-slate-800 text-slate-300 font-mono">
|
||||||
|
Gen {status?.nixosGeneration || 42}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<div className="hidden md:flex items-center gap-2 text-xs text-slate-400 border-l border-slate-800 pl-4">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
<span>ARC Hit: <strong className="text-emerald-400">{status?.arcHitRatioPercent || 98.6}%</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{gitops?.lastCommitSha && (
|
||||||
|
<div className="hidden lg:flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-800/60 border border-slate-700/50 text-xs font-mono text-slate-300">
|
||||||
|
<span className="text-slate-500">SHA:</span>
|
||||||
|
<span>{gitops.lastCommitSha.slice(0, 7)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onTriggerRebuild}
|
||||||
|
className="flex items-center gap-2 px-3.5 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-medium transition shadow-sm shadow-emerald-600/30"
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5 fill-current" />
|
||||||
|
<span>Apply Changes</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onReboot}
|
||||||
|
title="Reboot Appliance"
|
||||||
|
className="p-2 rounded-lg bg-slate-800/80 hover:bg-rose-500/20 text-slate-400 hover:text-rose-400 border border-slate-700/60 transition"
|
||||||
|
>
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-slate-950 text-slate-100 antialiased;
|
||||||
|
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #334155;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { App } from './App.js';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
export interface ZfsPool {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
allocated: string;
|
||||||
|
free: string;
|
||||||
|
fragmentation: string;
|
||||||
|
capacityPercent: number;
|
||||||
|
health: 'ONLINE' | 'DEGRADED' | 'FAULTED' | 'OFFLINE';
|
||||||
|
altroot: string;
|
||||||
|
scanStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZfsDataset {
|
||||||
|
name: string;
|
||||||
|
pool: string;
|
||||||
|
used: string;
|
||||||
|
available: string;
|
||||||
|
referenced: string;
|
||||||
|
mountpoint: string;
|
||||||
|
compression: string;
|
||||||
|
quota: string;
|
||||||
|
reservation: string;
|
||||||
|
recordsize: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZfsSnapshot {
|
||||||
|
name: string;
|
||||||
|
dataset: string;
|
||||||
|
snapshotTag: string;
|
||||||
|
creationTime: string;
|
||||||
|
usedBytes: string;
|
||||||
|
referencedBytes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnimportedPool {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
action?: string;
|
||||||
|
disks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmbShare {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
comment?: string;
|
||||||
|
readOnly: boolean;
|
||||||
|
browseable: boolean;
|
||||||
|
guestOk: boolean;
|
||||||
|
validUsers: string[];
|
||||||
|
timeMachine: boolean;
|
||||||
|
timeMachineMaxSize?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NfsExport {
|
||||||
|
path: string;
|
||||||
|
clients: { subnet: string; options: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppRuntime = 'systemd' | 'docker' | 'k3s';
|
||||||
|
|
||||||
|
export interface AppCatalogItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: 'Media' | 'Storage' | 'Productivity' | 'Security' | 'Automation';
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
defaultPort: number;
|
||||||
|
supportedRuntimes: AppRuntime[];
|
||||||
|
recommendedRuntime: AppRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstalledApp {
|
||||||
|
id: string;
|
||||||
|
appId: string;
|
||||||
|
name: string;
|
||||||
|
runtime: 'systemd' | 'docker' | 'k3s';
|
||||||
|
status: 'running' | 'stopped' | 'errored' | 'starting';
|
||||||
|
port: number;
|
||||||
|
version: string;
|
||||||
|
cpuUsagePercent: number;
|
||||||
|
memoryUsageBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitOpsStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
remoteUrl?: string;
|
||||||
|
branch: string;
|
||||||
|
lastCommitSha: string;
|
||||||
|
lastCommitMessage: string;
|
||||||
|
lastSyncTime?: string;
|
||||||
|
pendingChanges: boolean;
|
||||||
|
isClean: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitCommit {
|
||||||
|
hash: string;
|
||||||
|
author: string;
|
||||||
|
date: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
hostname: string;
|
||||||
|
uptimeSeconds: number;
|
||||||
|
cpuUsagePercent: number;
|
||||||
|
memoryTotalBytes: number;
|
||||||
|
memoryUsedBytes: number;
|
||||||
|
arcSizeBytes: number;
|
||||||
|
arcHitRatioPercent: number;
|
||||||
|
zfsPoolsCount: number;
|
||||||
|
activeSharesCount: number;
|
||||||
|
runningAppsCount: number;
|
||||||
|
osVersion: string;
|
||||||
|
nixosGeneration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
unit: string;
|
||||||
|
priority: 'info' | 'notice' | 'warning' | 'err' | 'crit';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Activity,
|
||||||
|
HardDrive,
|
||||||
|
Cpu,
|
||||||
|
Clock,
|
||||||
|
RefreshCw,
|
||||||
|
Zap,
|
||||||
|
Layers,
|
||||||
|
ArrowUpRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export const AnalyticsView: React.FC = () => {
|
||||||
|
const [timeRange, setTimeRange] = useState('1h');
|
||||||
|
const [refreshInterval, setRefreshInterval] = useState('10s');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">Perses Native Analytics</h2>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-semibold font-mono">
|
||||||
|
CNCF Perses
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Embedded telemetry and OpenZFS performance graphs replacing standalone Grafana
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 p-1 bg-slate-900 border border-slate-800 rounded-xl text-xs">
|
||||||
|
{['15m', '1h', '6h', '24h', '7d'].map((range) => (
|
||||||
|
<button
|
||||||
|
key={range}
|
||||||
|
onClick={() => setTimeRange(range)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg font-medium transition ${
|
||||||
|
timeRange === range ? 'bg-emerald-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="p-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white border border-slate-800 transition">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid of Perses Dashboard Panels */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Panel 1: ZFS ARC Hit Ratio */}
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-white">
|
||||||
|
<Zap className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span>ZFS ARC Hit Ratio (%)</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono font-bold text-emerald-400">98.6%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-40 flex items-end gap-1.5 pt-4 px-2 bg-slate-950/60 rounded-xl border border-slate-800/80">
|
||||||
|
{[97.8, 98.1, 98.4, 98.2, 98.6, 98.8, 98.5, 98.7, 98.4, 98.9, 98.6, 98.7, 98.5, 98.8, 98.6].map((val, i) => (
|
||||||
|
<div key={i} className="flex-1 bg-gradient-to-t from-emerald-600/40 to-emerald-400 rounded-t transition-all" style={{ height: `${(val - 90) * 10}%` }} title={`${val}%`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500 font-mono">
|
||||||
|
<span>Query: zfs_arc_hits / (zfs_arc_hits + zfs_arc_misses) * 100</span>
|
||||||
|
<span>Live</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel 2: Storage Pool Throughput (MB/s) */}
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-white">
|
||||||
|
<HardDrive className="h-4 w-4 text-sky-400" />
|
||||||
|
<span>Storage Pool Read / Write Bandwidth</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-slate-400">Peak: 142 MB/s</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-40 flex items-end gap-1.5 pt-4 px-2 bg-slate-950/60 rounded-xl border border-slate-800/80">
|
||||||
|
{[24, 45, 18, 62, 88, 142, 95, 30, 48, 70, 85, 40, 55, 90, 60].map((val, i) => (
|
||||||
|
<div key={i} className="flex-1 bg-gradient-to-t from-sky-600/40 to-sky-400 rounded-t transition-all" style={{ height: `${(val / 150) * 100}%` }} title={`${val} MB/s`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500 font-mono">
|
||||||
|
<span>Query: rate(node_disk_read_bytes_total[1m]) + write</span>
|
||||||
|
<span>Target: tank</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel 3: CPU Core Utilization */}
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-white">
|
||||||
|
<Cpu className="h-4 w-4 text-purple-400" />
|
||||||
|
<span>CPU Core Utilization</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-purple-300">12.4% Avg</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-40 flex items-end gap-1.5 pt-4 px-2 bg-slate-950/60 rounded-xl border border-slate-800/80">
|
||||||
|
{[8, 12, 14, 25, 42, 19, 11, 9, 15, 18, 30, 16, 12, 14, 10].map((val, i) => (
|
||||||
|
<div key={i} className="flex-1 bg-gradient-to-t from-purple-600/40 to-purple-400 rounded-t transition-all" style={{ height: `${val * 2}%` }} title={`${val}%`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500 font-mono">
|
||||||
|
<span>Query: rate(node_cpu_seconds_total)</span>
|
||||||
|
<span>4 Cores</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel 4: Memory & ARC Distribution */}
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-white">
|
||||||
|
<Layers className="h-4 w-4 text-amber-400" />
|
||||||
|
<span>Memory Footprint Breakdown</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-slate-300">13.2 / 32.0 GB</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-40 flex items-center justify-center bg-slate-950/60 rounded-xl border border-slate-800/80 p-4">
|
||||||
|
<div className="w-full space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className="text-slate-400">ZFS ARC Cache</span>
|
||||||
|
<span className="font-mono text-emerald-400 font-semibold">4.0 GB (100% Target)</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
|
||||||
|
<div className="bg-emerald-400 h-full rounded-full" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className="text-slate-400">Applications & Immich ML</span>
|
||||||
|
<span className="font-mono text-sky-400 font-semibold">9.2 GB</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
|
||||||
|
<div className="bg-sky-400 h-full rounded-full" style={{ width: '32%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500 font-mono">
|
||||||
|
<span>Query: node_memory_MemTotal_bytes</span>
|
||||||
|
<span>Free: 18.8 GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Layers,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
RotateCw,
|
||||||
|
ExternalLink,
|
||||||
|
Cpu,
|
||||||
|
Server,
|
||||||
|
Image,
|
||||||
|
Cloud,
|
||||||
|
Film,
|
||||||
|
FileText,
|
||||||
|
Lock,
|
||||||
|
Home,
|
||||||
|
CheckCircle2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { AppCatalogItem, InstalledApp, AppRuntime } from '../types/index.js';
|
||||||
|
|
||||||
|
interface AppsViewProps {
|
||||||
|
catalog: AppCatalogItem[];
|
||||||
|
installedApps: InstalledApp[];
|
||||||
|
onInstallApp: (appId: string, runtime: AppRuntime) => Promise<void>;
|
||||||
|
onSetStatus: (appId: string, status: 'running' | 'stopped') => Promise<void>;
|
||||||
|
onUninstall: (appId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppsView: React.FC<AppsViewProps> = ({
|
||||||
|
catalog,
|
||||||
|
installedApps,
|
||||||
|
onInstallApp,
|
||||||
|
onSetStatus,
|
||||||
|
onUninstall,
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'catalog' | 'installed'>('catalog');
|
||||||
|
const [installingId, setInstallingId] = useState<string | null>(null);
|
||||||
|
const [selectedRuntime, setSelectedRuntime] = useState<Record<string, AppRuntime>>({
|
||||||
|
immich: 'systemd',
|
||||||
|
nextcloud: 'docker',
|
||||||
|
jellyfin: 'docker',
|
||||||
|
paperless: 'systemd',
|
||||||
|
vaultwarden: 'systemd',
|
||||||
|
});
|
||||||
|
|
||||||
|
const getIcon = (iconName: string) => {
|
||||||
|
switch (iconName) {
|
||||||
|
case 'Image': return <Image className="h-6 w-6 text-emerald-400" />;
|
||||||
|
case 'Cloud': return <Cloud className="h-6 w-6 text-sky-400" />;
|
||||||
|
case 'Film': return <Film className="h-6 w-6 text-indigo-400" />;
|
||||||
|
case 'FileText': return <FileText className="h-6 w-6 text-amber-400" />;
|
||||||
|
case 'Lock': return <Lock className="h-6 w-6 text-rose-400" />;
|
||||||
|
default: return <Home className="h-6 w-6 text-purple-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstall = async (appId: string) => {
|
||||||
|
setInstallingId(appId);
|
||||||
|
try {
|
||||||
|
const runtime = selectedRuntime[appId] || 'docker';
|
||||||
|
await onInstallApp(appId, runtime);
|
||||||
|
setActiveTab('installed');
|
||||||
|
} finally {
|
||||||
|
setInstallingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">App Store & Service Runtime</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Deploy self-hosted workloads across Native Systemd, Rootless Docker, or Lightweight K3s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-1 bg-slate-900 border border-slate-800 rounded-xl flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('catalog')}
|
||||||
|
className={`px-3.5 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||||
|
activeTab === 'catalog' ? 'bg-emerald-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Catalog Store ({catalog.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('installed')}
|
||||||
|
className={`px-3.5 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||||
|
activeTab === 'installed' ? 'bg-emerald-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Installed Workloads ({installedApps.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Catalog Grid */}
|
||||||
|
{activeTab === 'catalog' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{catalog.map((item) => {
|
||||||
|
const isInstalled = installedApps.some((a) => a.appId === item.id);
|
||||||
|
const currentRuntime = selectedRuntime[item.id] || item.recommendedRuntime;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800 flex flex-col justify-between hover:border-slate-700 transition"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="p-2.5 rounded-xl bg-slate-950 border border-slate-800">
|
||||||
|
{getIcon(item.icon)}
|
||||||
|
</div>
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-slate-800 text-slate-300 font-mono">
|
||||||
|
{item.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-sm font-bold text-white mt-3.5">{item.name}</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1 leading-relaxed line-clamp-3">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 pt-4 border-t border-slate-800/80 space-y-3">
|
||||||
|
{/* Runtime selector */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Execution Runtime:</span>
|
||||||
|
<select
|
||||||
|
value={currentRuntime}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedRuntime({ ...selectedRuntime, [item.id]: e.target.value as AppRuntime })
|
||||||
|
}
|
||||||
|
disabled={isInstalled}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-lg px-2 py-1 text-slate-200 text-xs font-medium focus:outline-none focus:border-emerald-500"
|
||||||
|
>
|
||||||
|
{item.supportedRuntimes.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r === 'systemd' ? 'Native Systemd (Fastest)' : r === 'docker' ? 'Docker Compose' : 'K3s Cluster'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action button */}
|
||||||
|
{isInstalled ? (
|
||||||
|
<div className="w-full py-2 rounded-xl bg-slate-800/60 text-emerald-400 text-xs font-semibold flex items-center justify-center gap-1.5 border border-slate-700/50">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
<span>Installed & Active</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleInstall(item.id)}
|
||||||
|
disabled={installingId === item.id}
|
||||||
|
className="w-full py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white text-xs font-semibold transition shadow-sm"
|
||||||
|
>
|
||||||
|
{installingId === item.id ? 'Deploying...' : '1-Click Install'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Installed Workloads Tab */}
|
||||||
|
{activeTab === 'installed' && (
|
||||||
|
<div className="rounded-2xl bg-slate-900/80 border border-slate-800 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<Layers className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span>Active Workloads</span>
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-slate-400">{installedApps.length} services</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-800">
|
||||||
|
{installedApps.map((app) => (
|
||||||
|
<div key={app.id} className="p-5 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-slate-800/20 transition">
|
||||||
|
<div className="flex items-center gap-3.5">
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-slate-950 border border-slate-800 flex items-center justify-center shrink-0">
|
||||||
|
<Server className="h-5 w-5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="text-sm font-bold text-white">{app.name}</h4>
|
||||||
|
<span className={`text-[10px] px-2 py-0.5 rounded-full font-semibold ${
|
||||||
|
app.status === 'running'
|
||||||
|
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
|
||||||
|
: 'bg-slate-800 text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{app.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded bg-slate-800 text-slate-400 font-mono">
|
||||||
|
{app.runtime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-slate-400">
|
||||||
|
<span>Port: <strong className="text-slate-200 font-mono">{app.port}</strong></span>
|
||||||
|
<span>CPU: <strong className="text-slate-200 font-mono">{app.cpuUsagePercent}%</strong></span>
|
||||||
|
<span>RAM: <strong className="text-slate-200 font-mono">{(app.memoryUsageBytes / 1024 / 1024).toFixed(0)} MB</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={`http://${window.location.hostname || 'localhost'}:${app.port}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-medium border border-slate-700 transition"
|
||||||
|
>
|
||||||
|
<span>Open Web UI</span>
|
||||||
|
<ExternalLink className="h-3 w-3 text-slate-400" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{app.status === 'running' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onSetStatus(app.appId, 'stopped')}
|
||||||
|
className="p-2 rounded-lg bg-slate-800 hover:bg-amber-500/20 text-slate-400 hover:text-amber-400 border border-slate-700 transition"
|
||||||
|
title="Stop Service"
|
||||||
|
>
|
||||||
|
<Square className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onSetStatus(app.appId, 'running')}
|
||||||
|
className="p-2 rounded-lg bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-500/30 transition"
|
||||||
|
title="Start Service"
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5 fill-current" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onUninstall(app.appId)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-rose-500/20 text-slate-400 hover:text-rose-400 border border-slate-700 text-xs font-medium transition"
|
||||||
|
>
|
||||||
|
Uninstall
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
GitCommit as GitCommitIcon,
|
||||||
|
RotateCcw,
|
||||||
|
CheckCircle2,
|
||||||
|
RefreshCw,
|
||||||
|
ExternalLink,
|
||||||
|
Shield,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { GitOpsStatus, GitCommit } from '../types/index.js';
|
||||||
|
|
||||||
|
interface GitOpsViewProps {
|
||||||
|
status: GitOpsStatus | null;
|
||||||
|
commits: GitCommit[];
|
||||||
|
onRollback: (commitSha: string) => Promise<void>;
|
||||||
|
onTriggerRebuild: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GitOpsView: React.FC<GitOpsViewProps> = ({
|
||||||
|
status,
|
||||||
|
commits,
|
||||||
|
onRollback,
|
||||||
|
onTriggerRebuild,
|
||||||
|
}) => {
|
||||||
|
const [selectedChannel, setSelectedChannel] = useState<'stable' | 'beta' | 'nightly'>('stable');
|
||||||
|
const [rollingBackSha, setRollingBackSha] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleRollback = async (sha: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to rollback appliance configuration to commit ${sha.slice(0, 7)}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRollingBackSha(sha);
|
||||||
|
try {
|
||||||
|
await onRollback(sha);
|
||||||
|
onTriggerRebuild();
|
||||||
|
} finally {
|
||||||
|
setRollingBackSha(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">GitOps Engine & OS Updates</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Declarative infrastructure-as-code version control, disaster recovery, and automated updates
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onTriggerRebuild}
|
||||||
|
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-xs font-semibold text-white shadow-sm transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
<span>Synchronize & Rebuild</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitOps Status Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Remote Synchronization</span>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||||
|
<span className="text-sm font-bold text-white font-mono">{status?.branch || 'main'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-slate-400 truncate font-mono">
|
||||||
|
{status?.remoteUrl || 'ssh://git@git.lholz.de:2222/naxos/naxos-config.git'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Current Head Commit</span>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<GitCommitIcon className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span className="text-sm font-bold text-white font-mono">{status?.lastCommitSha?.slice(0, 7) || '5da58ae'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-slate-300 truncate">
|
||||||
|
{status?.lastCommitMessage || 'feat(storage): updated tank/backup quota'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">OS Update Channel</span>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<select
|
||||||
|
value={selectedChannel}
|
||||||
|
onChange={(e) => setSelectedChannel(e.target.value as any)}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1 text-slate-200 text-xs font-semibold focus:outline-none focus:border-emerald-500"
|
||||||
|
>
|
||||||
|
<option value="stable">Stable (24.11 Vicuna)</option>
|
||||||
|
<option value="beta">Beta Channel</option>
|
||||||
|
<option value="nightly">Nightly Unstable</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-[11px] text-emerald-400 flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
<span>Appliance is up to date</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commit History & Rollback Table */}
|
||||||
|
<div className="rounded-2xl bg-slate-900/80 border border-slate-800 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GitBranch className="h-4 w-4 text-emerald-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-white">Declarative Configuration Audit Trail</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400">Total {commits.length} generations</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-800">
|
||||||
|
{commits.map((c, i) => (
|
||||||
|
<div key={c.hash} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-3 hover:bg-slate-800/30 transition">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-emerald-400 font-semibold">{c.hash.slice(0, 7)}</span>
|
||||||
|
{i === 0 && (
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-semibold font-mono">
|
||||||
|
ACTIVE GENERATION
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium text-white">{c.message}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-[11px] text-slate-500">
|
||||||
|
<span>Author: {c.author}</span>
|
||||||
|
<span>Date: {c.date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{i !== 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRollback(c.hash)}
|
||||||
|
disabled={rollingBackSha === c.hash}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-rose-500/20 text-slate-300 hover:text-rose-300 text-xs font-medium border border-slate-700 transition shrink-0"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
<span>{rollingBackSha === c.hash ? 'Reverting...' : 'Rollback to here'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
FileTerminal,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Download,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
AlertCircle,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { LogEntry } from '../types/index.js';
|
||||||
|
|
||||||
|
interface LogsViewProps {
|
||||||
|
logs: LogEntry[];
|
||||||
|
selectedUnit: string;
|
||||||
|
onSelectUnit: (unit: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogsView: React.FC<LogsViewProps> = ({
|
||||||
|
logs,
|
||||||
|
selectedUnit,
|
||||||
|
onSelectUnit,
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isStreaming, setIsStreaming] = useState(true);
|
||||||
|
|
||||||
|
const units = ['all', 'zfs.target', 'smbd.service', 'immich.service', 'naxos-api.service', 'perses.service'];
|
||||||
|
|
||||||
|
const filteredLogs = logs.filter((log) => {
|
||||||
|
const matchesUnit = selectedUnit === 'all' || log.unit.includes(selectedUnit);
|
||||||
|
const matchesQuery = !searchQuery || log.message.toLowerCase().includes(searchQuery.toLowerCase()) || log.unit.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
return matchesUnit && matchesQuery;
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportLogs = () => {
|
||||||
|
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `naxos-logs-${new Date().toISOString()}.json`;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">System Logs & Real-Time Viewer</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Real-time journald logs with severity classification and service unit filtering
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsStreaming(!isStreaming)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium transition ${
|
||||||
|
isStreaming
|
||||||
|
? 'bg-emerald-600/20 text-emerald-400 border border-emerald-500/30'
|
||||||
|
: 'bg-slate-800 text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isStreaming ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 fill-current" />}
|
||||||
|
<span>{isStreaming ? 'Streaming Live' : 'Paused'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={exportLogs}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 text-xs font-medium border border-slate-800 transition"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
<span>Export Diagnostics</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Toolbar */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-3">
|
||||||
|
<div className="relative flex-1 w-full">
|
||||||
|
<Search className="h-4 w-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search log messages, services, or timestamps..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-slate-900 border border-slate-800 rounded-xl text-xs text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 p-1 bg-slate-900 border border-slate-800 rounded-xl overflow-x-auto w-full sm:w-auto">
|
||||||
|
{units.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u}
|
||||||
|
onClick={() => onSelectUnit(u)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition ${
|
||||||
|
selectedUnit === u
|
||||||
|
? 'bg-emerald-600 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u === 'all' ? 'All Services' : u.replace('.service', '').replace('.target', '')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Output Table / Feed */}
|
||||||
|
<div className="rounded-2xl bg-slate-950 border border-slate-800 font-mono text-xs overflow-hidden shadow-xl">
|
||||||
|
<div className="px-4 py-2.5 bg-slate-900 border-b border-slate-800 flex items-center justify-between text-slate-400 text-[11px]">
|
||||||
|
<span>Live journald stream</span>
|
||||||
|
<span>{filteredLogs.length} entries shown</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-850 max-h-[520px] overflow-y-auto">
|
||||||
|
{filteredLogs.map((entry, idx) => (
|
||||||
|
<div key={idx} className="p-3 hover:bg-slate-900/40 transition flex items-start gap-3">
|
||||||
|
<span className="text-slate-500 shrink-0 select-none text-[11px]">
|
||||||
|
{entry.timestamp.slice(11, 19)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="px-2 py-0.5 rounded bg-slate-800 text-slate-300 shrink-0 text-[10px] font-semibold">
|
||||||
|
{entry.unit}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={`flex-1 break-all ${
|
||||||
|
entry.priority === 'err' || entry.priority === 'crit'
|
||||||
|
? 'text-rose-400 font-semibold'
|
||||||
|
: entry.priority === 'warning'
|
||||||
|
? 'text-amber-400'
|
||||||
|
: 'text-slate-200'
|
||||||
|
}`}>
|
||||||
|
{entry.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
HardDrive,
|
||||||
|
Cpu,
|
||||||
|
Layers,
|
||||||
|
FolderSync,
|
||||||
|
ShieldCheck,
|
||||||
|
Activity,
|
||||||
|
ArrowUpRight,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { SystemStatus, ZfsPool, InstalledApp } from '../types/index.js';
|
||||||
|
|
||||||
|
interface OverviewViewProps {
|
||||||
|
status: SystemStatus | null;
|
||||||
|
pools: ZfsPool[];
|
||||||
|
apps: InstalledApp[];
|
||||||
|
onNavigate: (tab: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OverviewView: React.FC<OverviewViewProps> = ({
|
||||||
|
status,
|
||||||
|
pools,
|
||||||
|
apps,
|
||||||
|
onNavigate,
|
||||||
|
}) => {
|
||||||
|
const primaryPool = pools[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Top Banner / Welcome */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 p-6 rounded-2xl bg-gradient-to-r from-slate-900 via-slate-900/80 to-emerald-950/30 border border-slate-800">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">Appliance Overview</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Running {status?.osVersion || 'NaxOS 24.11'} on OpenZFS storage engine
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('storage')}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-200 transition border border-slate-700"
|
||||||
|
>
|
||||||
|
<span>Manage Pools</span>
|
||||||
|
<ArrowUpRight className="h-3.5 w-3.5 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('apps')}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-xs font-medium text-white transition shadow-sm shadow-emerald-600/30"
|
||||||
|
>
|
||||||
|
<span>App Store</span>
|
||||||
|
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Storage Pool Card */}
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/70 border border-slate-800/80 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Pool: {primaryPool?.name || 'tank'}</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||||
|
{primaryPool?.health || 'ONLINE'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-2xl font-bold text-white tracking-tight">
|
||||||
|
{primaryPool?.allocated || '8.2T'} <span className="text-xs text-slate-400 font-normal">/ {primaryPool?.size || '14.5T'}</span>
|
||||||
|
</div>
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="w-full bg-slate-800 h-2 rounded-full mt-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-emerald-500 to-teal-400 h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${primaryPool?.capacityPercent || 56}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-400">
|
||||||
|
<span>Free: {primaryPool?.free || '6.3T'}</span>
|
||||||
|
<span>Frag: {primaryPool?.fragmentation || '14%'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ZFS ARC Hit Ratio */}
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/70 border border-slate-800/80 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">ZFS ARC Cache</span>
|
||||||
|
<ShieldCheck className="h-4 w-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-2xl font-bold text-white tracking-tight">
|
||||||
|
{status?.arcHitRatioPercent || 98.6}%
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
ARC Size: <strong>4.0 GB</strong> (Max: 4.0 GB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-[11px] text-emerald-400 flex items-center gap-1">
|
||||||
|
<TrendingUp className="h-3 w-3" />
|
||||||
|
<span>Optimal cache hit performance</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workloads Card */}
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/70 border border-slate-800/80 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Running Workloads</span>
|
||||||
|
<Layers className="h-4 w-4 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-2xl font-bold text-white tracking-tight">
|
||||||
|
{apps.length || 2} <span className="text-xs text-slate-400 font-normal">Active Services</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Immich (Systemd), Jellyfin (Docker)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-[11px] text-slate-400">
|
||||||
|
Runtime Engine: <span className="text-slate-200">Docker + Systemd</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Shares */}
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/70 border border-slate-800/80 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Active File Shares</span>
|
||||||
|
<FolderSync className="h-4 w-4 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-2xl font-bold text-white tracking-tight">
|
||||||
|
{status?.activeSharesCount || 3}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
SMB (Time Machine) + NFS v4
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-[11px] text-slate-400">
|
||||||
|
Bonjour / mDNS discovery enabled
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Embedded Native Perses Analytics Panel */}
|
||||||
|
<div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4 text-emerald-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-white">Native Perses Telemetry</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('analytics')}
|
||||||
|
className="text-xs text-emerald-400 hover:text-emerald-300 font-medium flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>View Full Dashboards</span>
|
||||||
|
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Simulated Perses Panel 1: Throughput */}
|
||||||
|
<div className="p-4 rounded-xl bg-slate-950/60 border border-slate-800/80">
|
||||||
|
<div className="text-xs font-medium text-slate-300 mb-2">Pool Read/Write Throughput</div>
|
||||||
|
<div className="h-32 flex items-end gap-1 px-2 pt-4">
|
||||||
|
{[42, 65, 30, 85, 95, 120, 80, 45, 60, 90, 110, 75, 40, 95, 130, 85, 70, 90, 105, 140, 95, 60, 80, 115].map((val, i) => (
|
||||||
|
<div key={i} className="flex-1 bg-emerald-500/30 hover:bg-emerald-400 transition-colors rounded-t" style={{ height: `${(val / 150) * 100}%` }} title={`${val} MB/s`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500 mt-2 font-mono">
|
||||||
|
<span>-30m</span>
|
||||||
|
<span>115 MB/s Peak</span>
|
||||||
|
<span>Now</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simulated Perses Panel 2: Memory & ARC */}
|
||||||
|
<div className="p-4 rounded-xl bg-slate-950/60 border border-slate-800/80">
|
||||||
|
<div className="text-xs font-medium text-slate-300 mb-2">System Memory & ZFS ARC Distribution</div>
|
||||||
|
<div className="h-32 flex items-center justify-center">
|
||||||
|
<div className="w-full max-w-xs space-y-2">
|
||||||
|
<div className="flex justify-between text-xs text-slate-300">
|
||||||
|
<span>ZFS ARC Cache</span>
|
||||||
|
<span className="font-mono text-emerald-400">4.0 GB</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
|
||||||
|
<div className="bg-emerald-400 h-full rounded-full" style={{ width: '25%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-slate-300">
|
||||||
|
<span>Services & Containers</span>
|
||||||
|
<span className="font-mono text-sky-400">9.2 GB</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
|
||||||
|
<div className="bg-sky-400 h-full rounded-full" style={{ width: '45%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-slate-300">
|
||||||
|
<span>Free Available</span>
|
||||||
|
<span className="font-mono text-slate-400">18.8 GB</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
|
||||||
|
<div className="bg-slate-600 h-full rounded-full" style={{ width: '30%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
FolderSync,
|
||||||
|
Apple,
|
||||||
|
Users,
|
||||||
|
Shield,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
HardDrive,
|
||||||
|
CheckCircle2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { SmbShare, NfsExport } from '../types/index.js';
|
||||||
|
|
||||||
|
interface SharesViewProps {
|
||||||
|
smbShares: Record<string, SmbShare>;
|
||||||
|
nfsExports: NfsExport[];
|
||||||
|
onSaveSmbShare: (share: SmbShare) => Promise<void>;
|
||||||
|
onDeleteSmbShare: (name: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SharesView: React.FC<SharesViewProps> = ({
|
||||||
|
smbShares,
|
||||||
|
nfsExports,
|
||||||
|
onSaveSmbShare,
|
||||||
|
onDeleteSmbShare,
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'smb' | 'nfs'>('smb');
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [newShareName, setNewShareName] = useState('');
|
||||||
|
const [newSharePath, setNewSharePath] = useState('/tank/');
|
||||||
|
const [newShareGuest, setNewShareGuest] = useState(false);
|
||||||
|
const [newShareTimeMachine, setNewShareTimeMachine] = useState(false);
|
||||||
|
const [newShareTmSize, setNewShareTmSize] = useState('512G');
|
||||||
|
|
||||||
|
const handleCreateShare = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newShareName) return;
|
||||||
|
await onSaveSmbShare({
|
||||||
|
name: newShareName,
|
||||||
|
path: newSharePath,
|
||||||
|
readOnly: false,
|
||||||
|
browseable: true,
|
||||||
|
guestOk: newShareGuest,
|
||||||
|
validUsers: newShareGuest ? [] : ['admin'],
|
||||||
|
timeMachine: newShareTimeMachine,
|
||||||
|
timeMachineMaxSize: newShareTimeMachine ? newShareTmSize : undefined,
|
||||||
|
});
|
||||||
|
setShowAddModal(false);
|
||||||
|
setNewShareName('');
|
||||||
|
setNewSharePath('/tank/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">Network File Sharing</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Samba (SMB) with Apple Time Machine support and high-performance NFS exports
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1 bg-slate-900 border border-slate-800 rounded-xl flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('smb')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||||
|
activeTab === 'smb' ? 'bg-emerald-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Samba (SMB)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('nfs')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||||
|
activeTab === 'nfs' ? 'bg-emerald-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
NFS Exports
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-xs font-semibold text-white shadow-sm transition"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span>Create Share</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SMB Shares Table */}
|
||||||
|
{activeTab === 'smb' && (
|
||||||
|
<div className="rounded-2xl bg-slate-900/80 border border-slate-800 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<FolderSync className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span>Samba (SMB) Shares</span>
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-slate-400">{Object.keys(smbShares).length} shares active</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead className="bg-slate-950/60 text-slate-400 border-b border-slate-800 font-medium">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3">Share Name</th>
|
||||||
|
<th className="px-4 py-3">Storage Path</th>
|
||||||
|
<th className="px-4 py-3">Access Mode</th>
|
||||||
|
<th className="px-4 py-3">Apple Time Machine</th>
|
||||||
|
<th className="px-4 py-3">Valid Users</th>
|
||||||
|
<th className="px-4 py-3 text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60 text-slate-300">
|
||||||
|
{Object.entries(smbShares).map(([name, share]) => (
|
||||||
|
<tr key={name} className="hover:bg-slate-800/30 transition">
|
||||||
|
<td className="px-5 py-3.5 font-medium text-white font-mono">[{share.name}]</td>
|
||||||
|
<td className="px-4 py-3.5 font-mono text-slate-400">{share.path}</td>
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
{share.guestOk ? (
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-sky-500/10 text-sky-400 border border-sky-500/20 text-[10px] font-semibold">
|
||||||
|
Guest / Public
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-slate-800 text-slate-300 border border-slate-700 text-[10px] font-semibold">
|
||||||
|
Authenticated Only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
{share.timeMachine ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-emerald-400 font-medium">
|
||||||
|
<Apple className="h-3.5 w-3.5" />
|
||||||
|
<span>Enabled {share.timeMachineMaxSize ? `(${share.timeMachineMaxSize})` : ''}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500">Disabled</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5 text-slate-400">
|
||||||
|
{share.validUsers.length > 0 ? share.validUsers.join(', ') : 'All authorized'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteSmbShare(name)}
|
||||||
|
className="p-1 rounded hover:bg-rose-500/20 text-slate-500 hover:text-rose-400 transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* NFS Exports Table */}
|
||||||
|
{activeTab === 'nfs' && (
|
||||||
|
<div className="rounded-2xl bg-slate-900/80 border border-slate-800 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white">NFS Exports</h3>
|
||||||
|
<span className="text-xs text-slate-400">{nfsExports.length} export rules</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-800 text-xs">
|
||||||
|
{nfsExports.map((exp, idx) => (
|
||||||
|
<div key={idx} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-3 hover:bg-slate-800/30 transition">
|
||||||
|
<div>
|
||||||
|
<div className="font-mono font-medium text-white">{exp.path}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-slate-400">
|
||||||
|
<span>Allowed Clients:</span>
|
||||||
|
{exp.clients.map((c, i) => (
|
||||||
|
<span key={i} className="px-2 py-0.5 rounded bg-slate-800 text-emerald-300 font-mono text-[11px]">
|
||||||
|
{c.subnet} ({c.options})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Share Modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl p-6 space-y-4">
|
||||||
|
<h3 className="text-base font-bold text-white">Create New SMB Share</h3>
|
||||||
|
<form onSubmit={handleCreateShare} className="space-y-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 font-medium mb-1">Share Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newShareName}
|
||||||
|
onChange={(e) => setNewShareName(e.target.value)}
|
||||||
|
placeholder="e.g. photos, documents"
|
||||||
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white focus:outline-none focus:border-emerald-500 font-mono"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 font-medium mb-1">Storage Path</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newSharePath}
|
||||||
|
onChange={(e) => setNewSharePath(e.target.value)}
|
||||||
|
placeholder="/tank/..."
|
||||||
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white focus:outline-none focus:border-emerald-500 font-mono"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-950 border border-slate-800">
|
||||||
|
<div>
|
||||||
|
<span className="text-white font-medium block">Public Guest Access</span>
|
||||||
|
<span className="text-slate-500 text-[11px]">Allow connecting without credentials</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newShareGuest}
|
||||||
|
onChange={(e) => setNewShareGuest(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded accent-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 p-3 rounded-xl bg-slate-950 border border-slate-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-white font-medium block">macOS Time Machine Target</span>
|
||||||
|
<span className="text-slate-500 text-[11px]">Enables Apple vfs_fruit compatibility</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newShareTimeMachine}
|
||||||
|
onChange={(e) => setNewShareTimeMachine(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded accent-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newShareTimeMachine && (
|
||||||
|
<div className="pt-2 border-t border-slate-800/80">
|
||||||
|
<label className="block text-slate-400 mb-1">Advertised Backup Quota Size</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newShareTmSize}
|
||||||
|
onChange={(e) => setNewShareTmSize(e.target.value)}
|
||||||
|
placeholder="512G"
|
||||||
|
className="w-full px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg text-white font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold shadow-sm"
|
||||||
|
>
|
||||||
|
Save & Publish
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
HardDrive,
|
||||||
|
Database,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Clock,
|
||||||
|
RotateCcw,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
FolderTree,
|
||||||
|
FileBox,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { ZfsPool, ZfsDataset, ZfsSnapshot, UnimportedPool } from '../types/index.js';
|
||||||
|
|
||||||
|
interface StorageViewProps {
|
||||||
|
pools: ZfsPool[];
|
||||||
|
datasets: ZfsDataset[];
|
||||||
|
snapshots: ZfsSnapshot[];
|
||||||
|
unimportedPools: UnimportedPool[];
|
||||||
|
onRefresh: () => void;
|
||||||
|
onImportPool: (poolName: string) => Promise<void>;
|
||||||
|
onScrubPool: (poolName: string) => Promise<void>;
|
||||||
|
onCreateSnapshot: (dataset: string, tag: string) => Promise<void>;
|
||||||
|
onRollbackSnapshot: (snapshotName: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StorageView: React.FC<StorageViewProps> = ({
|
||||||
|
pools,
|
||||||
|
datasets,
|
||||||
|
snapshots,
|
||||||
|
unimportedPools,
|
||||||
|
onRefresh,
|
||||||
|
onImportPool,
|
||||||
|
onScrubPool,
|
||||||
|
onCreateSnapshot,
|
||||||
|
onRollbackSnapshot,
|
||||||
|
}) => {
|
||||||
|
const [activeSubTab, setActiveSubTab] = useState<'pools' | 'datasets' | 'snapshots' | 'migration'>('pools');
|
||||||
|
const [selectedPool, setSelectedPool] = useState<string>(pools[0]?.name || 'tank');
|
||||||
|
const [importingPoolName, setImportingPoolName] = useState<string | null>(null);
|
||||||
|
const [importSuccess, setImportSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleImport = async (poolName: string) => {
|
||||||
|
setImportingPoolName(poolName);
|
||||||
|
try {
|
||||||
|
await onImportPool(poolName);
|
||||||
|
setImportSuccess(`Pool '${poolName}' was successfully imported without data loss!`);
|
||||||
|
setTimeout(() => setImportSuccess(null), 6000);
|
||||||
|
} finally {
|
||||||
|
setImportingPoolName(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Sub-navigation Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">OpenZFS Storage Engine</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Enterprise storage layout, datasets, snapshot policies, and foreign pool migration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 p-1 bg-slate-900 border border-slate-800 rounded-xl">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSubTab('pools')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||||
|
activeSubTab === 'pools'
|
||||||
|
? 'bg-emerald-600 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Storage Pools ({pools.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSubTab('datasets')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||||
|
activeSubTab === 'datasets'
|
||||||
|
? 'bg-emerald-600 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Datasets ({datasets.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSubTab('snapshots')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||||
|
activeSubTab === 'snapshots'
|
||||||
|
? 'bg-emerald-600 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Snapshots ({snapshots.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSubTab('migration')}
|
||||||
|
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||||
|
activeSubTab === 'migration'
|
||||||
|
? 'bg-amber-600 text-white shadow-sm'
|
||||||
|
: 'text-amber-400 hover:bg-amber-500/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Pool Migration</span>
|
||||||
|
{unimportedPools.length > 0 && (
|
||||||
|
<span className="h-2 w-2 rounded-full bg-amber-400 animate-ping" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importSuccess && (
|
||||||
|
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-center gap-3 text-emerald-300 text-xs">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-emerald-400 shrink-0" />
|
||||||
|
<span>{importSuccess}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pools Tab */}
|
||||||
|
{activeSubTab === 'pools' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{pools.map((pool) => (
|
||||||
|
<div key={pool.name} className="p-6 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-xl bg-slate-800 text-emerald-400">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||||
|
<span>{pool.name}</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-semibold font-mono">
|
||||||
|
{pool.health}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
OpenZFS Pool • ashift=12 • Compression=lz4/zstd
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onScrubPool(pool.name)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-medium border border-slate-700 transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
<span>Run Scrub</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pool Capacity Bar */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex justify-between text-xs text-slate-300">
|
||||||
|
<span>Capacity Used: <strong>{pool.allocated}</strong> / {pool.size} ({pool.capacityPercent}%)</span>
|
||||||
|
<span>Available Free: <strong>{pool.free}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-800 h-2.5 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-emerald-500 h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${pool.capacityPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status summary */}
|
||||||
|
<div className="pt-2 border-t border-slate-800/80 flex flex-wrap items-center justify-between text-xs text-slate-400 gap-2">
|
||||||
|
<span>Last Scrub: {pool.scanStatus || 'Completed with 0 errors'}</span>
|
||||||
|
<span>Fragmentation: <strong className="text-slate-200 font-mono">{pool.fragmentation}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Datasets Tab */}
|
||||||
|
{activeSubTab === 'datasets' && (
|
||||||
|
<div className="rounded-2xl bg-slate-900/80 border border-slate-800 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<FolderTree className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span>ZFS Datasets & Mountpoints</span>
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-slate-400">Total {datasets.length} datasets</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead className="bg-slate-950/60 text-slate-400 border-b border-slate-800 font-medium">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3">Dataset Name</th>
|
||||||
|
<th className="px-4 py-3">Mountpoint</th>
|
||||||
|
<th className="px-4 py-3">Used</th>
|
||||||
|
<th className="px-4 py-3">Available</th>
|
||||||
|
<th className="px-4 py-3">Compression</th>
|
||||||
|
<th className="px-4 py-3">Record Size</th>
|
||||||
|
<th className="px-4 py-3">Quota</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60 text-slate-300">
|
||||||
|
{datasets.map((ds) => (
|
||||||
|
<tr key={ds.name} className="hover:bg-slate-800/30 transition">
|
||||||
|
<td className="px-5 py-3.5 font-medium text-white flex items-center gap-2 font-mono">
|
||||||
|
<FileBox className="h-3.5 w-3.5 text-slate-400 shrink-0" />
|
||||||
|
<span>{ds.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5 font-mono text-slate-400">{ds.mountpoint}</td>
|
||||||
|
<td className="px-4 py-3.5 font-mono text-emerald-400 font-semibold">{ds.used}</td>
|
||||||
|
<td className="px-4 py-3.5 font-mono text-slate-400">{ds.available}</td>
|
||||||
|
<td className="px-4 py-3.5 font-mono">
|
||||||
|
<span className="px-2 py-0.5 rounded bg-slate-800 text-slate-300">
|
||||||
|
{ds.compression}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5 font-mono text-slate-400">{ds.recordsize}</td>
|
||||||
|
<td className="px-4 py-3.5 font-mono text-slate-400">
|
||||||
|
{ds.quota === 'none' ? <span className="text-slate-600">None</span> : ds.quota}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Snapshots Tab */}
|
||||||
|
{activeSubTab === 'snapshots' && (
|
||||||
|
<div className="rounded-2xl bg-slate-900/80 border border-slate-800 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span>ZFS Point-in-Time Snapshots</span>
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-slate-400">Total {snapshots.length} snapshots</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead className="bg-slate-950/60 text-slate-400 border-b border-slate-800 font-medium">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3">Snapshot Name</th>
|
||||||
|
<th className="px-4 py-3">Dataset</th>
|
||||||
|
<th className="px-4 py-3">Created</th>
|
||||||
|
<th className="px-4 py-3">Unique Used</th>
|
||||||
|
<th className="px-4 py-3 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60 text-slate-300">
|
||||||
|
{snapshots.map((snap) => (
|
||||||
|
<tr key={snap.name} className="hover:bg-slate-800/30 transition">
|
||||||
|
<td className="px-5 py-3 font-mono font-medium text-white">{snap.name}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-slate-400">{snap.dataset}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-400">{snap.creationTime}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-emerald-400">{snap.usedBytes}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => onRollbackSnapshot(snap.name)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-slate-800 hover:bg-rose-500/20 text-slate-300 hover:text-rose-300 text-[11px] font-medium transition border border-slate-700"
|
||||||
|
title="Rollback dataset to this exact snapshot state"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
<span>Rollback</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Foreign Pool Migration Wizard */}
|
||||||
|
{activeSubTab === 'migration' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-6 rounded-2xl bg-amber-500/5 border border-amber-500/20 space-y-3">
|
||||||
|
<div className="flex items-center gap-2.5 text-amber-400 font-semibold text-sm">
|
||||||
|
<ShieldCheck className="h-5 w-5" />
|
||||||
|
<span>Zero-Data-Loss Migration Engine</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-300 leading-relaxed">
|
||||||
|
NaxOS provides first-class support for discovering and safely importing existing ZFS storage pools created on <strong>TrueNAS CORE/SCALE</strong> or raw <strong>NixOS systems (such as nixos-lukas)</strong>. The migration engine operates with safety-first guarantees: existing datasets, quotas, and critical application assets (such as your Immich photo library in <code>tank/media/photos</code>) are preserved untouched.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white">Detected Foreign ZFS Pools</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Unimported pools ready for adoption</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-200 border border-slate-700 transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
<span>Re-scan Disks</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unimportedPools.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-slate-500">
|
||||||
|
No foreign unimported pools detected. All connected pools are currently active.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{unimportedPools.map((pool) => (
|
||||||
|
<div key={pool.name} className="p-4 rounded-xl bg-slate-950/70 border border-slate-800 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-white font-mono">{pool.name}</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 font-semibold border border-emerald-500/20">
|
||||||
|
{pool.state}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 font-mono">ID: {pool.id}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{pool.status}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-[11px] text-slate-500">
|
||||||
|
<span>Member Disks:</span>
|
||||||
|
{pool.disks.map((d) => (
|
||||||
|
<span key={d} className="px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 font-mono">
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleImport(pool.name)}
|
||||||
|
disabled={importingPoolName === pool.name}
|
||||||
|
className="px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white text-xs font-semibold shadow-sm transition shrink-0"
|
||||||
|
>
|
||||||
|
{importingPoolName === pool.name ? 'Importing Safely...' : 'Safe Import & Adopt'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
HardDrive,
|
||||||
|
Network,
|
||||||
|
UserCheck,
|
||||||
|
CheckCircle2,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
Database,
|
||||||
|
ShieldCheck,
|
||||||
|
Server,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export const WizardView: React.FC = () => {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [storageMode, setStorageMode] = useState<'create' | 'import'>('import');
|
||||||
|
const [poolName, setPoolName] = useState('tank');
|
||||||
|
const [hostname, setHostname] = useState('naxos');
|
||||||
|
const [adminUser, setAdminUser] = useState('admin');
|
||||||
|
const [adminPassword, setAdminPassword] = useState('');
|
||||||
|
const [gitopsUrl, setGitopsUrl] = useState('ssh://git@git.lholz.de:2222/naxos/naxos-config.git');
|
||||||
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
|
const handleFinish = () => {
|
||||||
|
setIsDone(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold text-white">NaxOS Appliance Setup Wizard</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Step-by-step guided configuration for storage layout, network, and security
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Steps */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 rounded-2xl bg-slate-900 border border-slate-800">
|
||||||
|
{[
|
||||||
|
{ num: 1, title: 'Storage & Pools' },
|
||||||
|
{ num: 2, title: 'Network' },
|
||||||
|
{ num: 3, title: 'Credentials' },
|
||||||
|
{ num: 4, title: 'GitOps & Finish' },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.num} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold transition ${
|
||||||
|
step === s.num
|
||||||
|
? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/30'
|
||||||
|
: step > s.num
|
||||||
|
? 'bg-emerald-500/20 text-emerald-400'
|
||||||
|
: 'bg-slate-800 text-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step > s.num ? <CheckCircle2 className="h-4 w-4" /> : s.num}
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium hidden sm:inline ${step === s.num ? 'text-white' : 'text-slate-500'}`}>
|
||||||
|
{s.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Container */}
|
||||||
|
<div className="p-8 rounded-2xl bg-slate-900 border border-slate-800 space-y-6">
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span>Storage Architecture & ZFS Pool</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStorageMode('import')}
|
||||||
|
className={`p-4 rounded-xl border text-left transition ${
|
||||||
|
storageMode === 'import'
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500 text-white'
|
||||||
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-bold text-xs">Safe Import Existing Pool</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-1">
|
||||||
|
Import TrueNAS or nixos-lukas pool without formatting. Preserves Immich photo library.
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStorageMode('create')}
|
||||||
|
className={`p-4 rounded-xl border text-left transition ${
|
||||||
|
storageMode === 'create'
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500 text-white'
|
||||||
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-bold text-xs">Create New Pool</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-1">
|
||||||
|
Initialize fresh ZFS mirror or RAID-Z pool across raw disks with ashift=12.
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">Target Pool Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={poolName}
|
||||||
|
onChange={(e) => setPoolName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white font-mono text-xs focus:outline-none focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||||
|
<Network className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span>Network Configuration</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">Appliance Hostname</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hostname}
|
||||||
|
onChange={(e) => setHostname(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white font-mono text-xs focus:outline-none focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-400">
|
||||||
|
DHCP will automatically request an IP address and advertise mDNS name <strong className="text-white font-mono">{hostname}.local</strong> via Avahi.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||||
|
<UserCheck className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span>Administrator Credentials</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">Admin Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={adminUser}
|
||||||
|
onChange={(e) => setAdminUser(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white font-mono text-xs focus:outline-none focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">Admin Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={adminPassword}
|
||||||
|
onChange={(e) => setAdminPassword(e.target.value)}
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white font-mono text-xs focus:outline-none focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span>GitOps Remote & Final Validation</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">Remote Git Repository (Gitea)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={gitopsUrl}
|
||||||
|
onChange={(e) => setGitopsUrl(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white font-mono text-xs focus:outline-none focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-slate-500 mt-1">
|
||||||
|
All future web dashboard configuration adjustments will be committed here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDone && (
|
||||||
|
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-center gap-3 text-emerald-300 text-xs">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-emerald-400 shrink-0" />
|
||||||
|
<span>NaxOS Appliance successfully initialized and ready for production!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex justify-between pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={step === 1}
|
||||||
|
onClick={() => setStep(step - 1)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 disabled:opacity-30 text-slate-300 text-xs font-medium"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{step < 4 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep(step + 1)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold shadow-sm"
|
||||||
|
>
|
||||||
|
<span>Next Step</span>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFinish}
|
||||||
|
className="flex items-center gap-1.5 px-5 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold shadow-sm"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
<span>Complete Setup</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
naxos: {
|
||||||
|
50: '#f0fdf4',
|
||||||
|
100: '#dcfce7',
|
||||||
|
500: '#22c55e',
|
||||||
|
600: '#16a34a',
|
||||||
|
700: '#15803d',
|
||||||
|
900: '#14532d',
|
||||||
|
},
|
||||||
|
slate: {
|
||||||
|
850: '#151e2e',
|
||||||
|
950: '#0b1120',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8088',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/perses': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user