ZMB Webui: Complete Project – Rebrand & Initial Clean Commit
ARCHITECTURE ============ Backend: FastAPI + uvicorn (port 8000) - JWT authentication with PAM system users - ZFS CLI wrapper with caching (30-60s TTL) - WebSocket pool status broadcaster (30s interval) - Services: auth, zfs_runner, file_manager, shares, identities, system_info - Routers: pools, datasets, snapshots, shares, identities, navigator, system Frontend: Next.js 15 + TypeScript (static export) - Incremental Static Regeneration (ISR) for weak hardware - Type-safe API client (lib/api.ts) - Dark mode + custom Tailwind theme - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc. DEPLOYMENT ========== Test Target: 192.168.1.179:8090 (Debian LXC) Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64) Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh) FEATURES COMPLETED ================== Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage) - Real-time stats with color-coded progress bars - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns) - ISR-optimized for fast loads on weak hardware REBRANDING ========== Renamed throughout: - Project: 'ZFS Manager' → 'ZMB Webui' - Services: 'zfs-manager' → 'zmb-webui' - Systemd units: zfs-manager-backend → zmb-webui-backend - Configuration files and documentation Co-Authored-By: Patrick <patrick@perlbach24.de>
This commit is contained in:
@@ -0,0 +1,747 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { api, Dataset, SambaShare, NfsShare } from "@/lib/api"
|
||||
import { Header } from "@/components/Header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog } from "@/components/ui/dialog"
|
||||
import { Plus, Trash2, RefreshCw, ChevronRight, ChevronDown } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
export default function DatasetsPage() {
|
||||
const [tab, setTab] = useState<"datasets" | "shares">("datasets")
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([])
|
||||
const [sambaShares, setSambaShares] = useState<SambaShare[]>([])
|
||||
const [nfsShares, setNfsShares] = useState<NfsShare[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedDatasets, setExpandedDatasets] = useState<Set<string>>(new Set())
|
||||
const [poolTabs, setPoolTabs] = useState<Map<string, string>>(new Map())
|
||||
|
||||
// Dialogs
|
||||
const [showCreateDataset, setShowCreateDataset] = useState(false)
|
||||
const [showCreateSambaShare, setShowCreateSambaShare] = useState(false)
|
||||
const [showCreateNfsShare, setShowCreateNfsShare] = useState(false)
|
||||
const [deleteDataset, setDeleteDataset] = useState<string | null>(null)
|
||||
const [deleteSambaShare, setDeleteSambaShare] = useState<string | null>(null)
|
||||
const [deleteNfsShare, setDeleteNfsShare] = useState<string | null>(null)
|
||||
|
||||
// Form states
|
||||
const [newDatasetName, setNewDatasetName] = useState("")
|
||||
const [newSambaName, setNewSambaName] = useState("")
|
||||
const [newSambaPath, setNewSambaPath] = useState("")
|
||||
const [newSambaComment, setNewSambaComment] = useState("")
|
||||
const [newNfsPath, setNewNfsPath] = useState("")
|
||||
const [newNfsClients, setNewNfsClients] = useState("")
|
||||
const [newNfsOptions, setNewNfsOptions] = useState("ro,sync,no_subtree_check")
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [ds, samba, nfs] = await Promise.all([
|
||||
api.getDatasets(),
|
||||
api.getSambaShares(),
|
||||
api.getNfsShares(),
|
||||
])
|
||||
setDatasets(ds)
|
||||
setSambaShares(samba)
|
||||
setNfsShares(nfs)
|
||||
} catch (err) {
|
||||
console.error("Failed to load data:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateDataset = async () => {
|
||||
if (!newDatasetName.trim()) return
|
||||
try {
|
||||
// Create dataset via API
|
||||
await api.createDataset(newDatasetName, {})
|
||||
setNewDatasetName("")
|
||||
setShowCreateDataset(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error("Failed to create dataset:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteDataset = async (name: string) => {
|
||||
try {
|
||||
await api.deleteDataset(name)
|
||||
setDeleteDataset(null)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error("Failed to delete dataset:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateSambaShare = async () => {
|
||||
if (!newSambaName.trim() || !newSambaPath.trim()) return
|
||||
try {
|
||||
await api.createSambaShare({
|
||||
name: newSambaName,
|
||||
path: newSambaPath,
|
||||
comment: newSambaComment || undefined,
|
||||
})
|
||||
setNewSambaName("")
|
||||
setNewSambaPath("")
|
||||
setNewSambaComment("")
|
||||
setShowCreateSambaShare(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error("Failed to create Samba share:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSambaShare = async (name: string) => {
|
||||
try {
|
||||
await api.deleteSambaShare(name)
|
||||
setDeleteSambaShare(null)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error("Failed to delete Samba share:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateNfsShare = async () => {
|
||||
if (!newNfsPath.trim() || !newNfsClients.trim()) return
|
||||
try {
|
||||
await api.createNfsShare({
|
||||
path: newNfsPath,
|
||||
clients: newNfsClients,
|
||||
options: newNfsOptions || undefined,
|
||||
})
|
||||
setNewNfsPath("")
|
||||
setNewNfsClients("")
|
||||
setNewNfsOptions("ro,sync,no_subtree_check")
|
||||
setShowCreateNfsShare(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error("Failed to create NFS share:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteNfsShare = async (path: string) => {
|
||||
try {
|
||||
await api.deleteNfsShare(path)
|
||||
setDeleteNfsShare(null)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
console.error("Failed to delete NFS share:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B"
|
||||
const k = 1024
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]
|
||||
}
|
||||
|
||||
const getDatasetDepth = (name: string): number => {
|
||||
return name.split("/").length - 1
|
||||
}
|
||||
|
||||
const getTopLevelDatasets = (): Dataset[] => {
|
||||
return datasets.filter((ds) => ds.name.split("/").length === 1)
|
||||
}
|
||||
|
||||
const getPoolStats = (poolName: string) => {
|
||||
const poolDatasets = datasets.filter((ds) => ds.name === poolName || ds.name.startsWith(poolName + "/"))
|
||||
const totalUsed = poolDatasets.reduce((sum, ds) => sum + (ds.used || 0), 0)
|
||||
const totalAvail = poolDatasets[0]?.avail || 0
|
||||
const totalSize = totalUsed + totalAvail
|
||||
const usagePercent = totalSize > 0 ? (totalUsed / totalSize) * 100 : 0
|
||||
|
||||
return { totalUsed, totalAvail, totalSize, usagePercent }
|
||||
}
|
||||
|
||||
const getChildDatasets = (parent: string): Dataset[] => {
|
||||
const prefix = parent + "/"
|
||||
return datasets.filter((ds) => ds.name.startsWith(prefix) && ds.name !== parent)
|
||||
}
|
||||
|
||||
const toggleExpand = (name: string) => {
|
||||
const newExpanded = new Set(expandedDatasets)
|
||||
if (newExpanded.has(name)) {
|
||||
newExpanded.delete(name)
|
||||
} else {
|
||||
newExpanded.add(name)
|
||||
}
|
||||
setExpandedDatasets(newExpanded)
|
||||
}
|
||||
|
||||
const renderDatasetTree = (parent?: string): React.ReactNode[] => {
|
||||
const items: React.ReactNode[] = []
|
||||
const datasetList = parent ? getChildDatasets(parent) : getTopLevelDatasets()
|
||||
|
||||
datasetList.forEach((ds) => {
|
||||
const children = getChildDatasets(ds.name)
|
||||
const isExpanded = expandedDatasets.has(ds.name)
|
||||
const depth = getDatasetDepth(ds.name)
|
||||
|
||||
items.push(
|
||||
<tr key={ds.name} className="border-b border-border hover:bg-muted/50">
|
||||
<td className="px-4 py-3 font-mono text-xs" style={{ paddingLeft: `${depth * 24 + 16}px` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{children.length > 0 && (
|
||||
<button
|
||||
onClick={() => toggleExpand(ds.name)}
|
||||
className="p-0 hover:bg-muted rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{children.length === 0 && <div className="w-4" />}
|
||||
<span>{ds.name.split("/").pop()}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{ds.type}</td>
|
||||
<td className="px-4 py-3">{formatBytes(ds.used || 0)}</td>
|
||||
<td className="px-4 py-3 text-xs">{ds.mountpoint || "—"}</td>
|
||||
<td className="px-4 py-3">{ds.compression || "off"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => setDeleteDataset(ds.name)}
|
||||
className="text-destructive hover:underline"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
if (isExpanded && children.length > 0) {
|
||||
items.push(...renderDatasetTree(ds.name))
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold">Datasets & Shares</h1>
|
||||
<Button onClick={loadData} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-2 mb-6 border-b border-border">
|
||||
<button
|
||||
onClick={() => setTab("datasets")}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
tab === "datasets"
|
||||
? "text-foreground border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Datasets
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("shares")}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
tab === "shares"
|
||||
? "text-foreground border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Shares (Samba & NFS)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Datasets Tab */}
|
||||
{tab === "datasets" && (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-4">
|
||||
<Button onClick={() => setShowCreateDataset(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Dataset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{getTopLevelDatasets().map((pool) => {
|
||||
const stats = getPoolStats(pool.name)
|
||||
const currentPoolTab = poolTabs.get(pool.name) || "filesystems"
|
||||
const childDatasets = getChildDatasets(pool.name)
|
||||
|
||||
return (
|
||||
<Card key={pool.name}>
|
||||
{/* Pool Header */}
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-xl">{pool.name}</CardTitle>
|
||||
<Badge variant="outline">ONLINE</Badge>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDeleteDataset(pool.name)}
|
||||
className="text-destructive hover:underline"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pool Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Size</p>
|
||||
<p className="font-bold">{formatBytes(stats.totalSize)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Allocated</p>
|
||||
<p className="font-bold">{formatBytes(stats.totalUsed)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Free</p>
|
||||
<p className="font-bold">{formatBytes(stats.totalAvail)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Fragmentation</p>
|
||||
<p className="font-bold">0%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Usage</p>
|
||||
<p className="font-bold">{stats.usagePercent.toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-6 bg-muted rounded overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-500"
|
||||
style={{ width: `${stats.usagePercent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-green-500"
|
||||
style={{ width: `${100 - stats.usagePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{stats.usagePercent.toFixed(2)}% Allocated • {(100 - stats.usagePercent).toFixed(2)}% Free
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<CardContent>
|
||||
<div className="border-b border-border mb-4">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setPoolTabs(new Map(poolTabs).set(pool.name, "filesystems"))}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
currentPoolTab === "filesystems"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
File Systems ({childDatasets.length + 1})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPoolTabs(new Map(poolTabs).set(pool.name, "snapshots"))}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
currentPoolTab === "snapshots"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Snapshots
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPoolTabs(new Map(poolTabs).set(pool.name, "status"))}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
currentPoolTab === "status"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Systems Tab */}
|
||||
{currentPoolTab === "filesystems" && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-xs">Name</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-xs">Type</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-xs">Used</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-xs">Available</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-xs">Mountpoint</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-xs">Compression</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="px-4 py-2 font-mono text-xs">{pool.name}</td>
|
||||
<td className="px-4 py-2 text-xs">{pool.type}</td>
|
||||
<td className="px-4 py-2 text-xs">{formatBytes(pool.used || 0)}</td>
|
||||
<td className="px-4 py-2 text-xs">{formatBytes(pool.avail || 0)}</td>
|
||||
<td className="px-4 py-2 text-xs">{pool.mountpoint || "—"}</td>
|
||||
<td className="px-4 py-2 text-xs">{pool.compression || "off"}</td>
|
||||
</tr>
|
||||
{childDatasets.map((ds) => (
|
||||
<tr key={ds.name} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="px-4 py-2 font-mono text-xs" style={{ paddingLeft: "32px" }}>
|
||||
{ds.name.split("/").pop()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">{ds.type}</td>
|
||||
<td className="px-4 py-2 text-xs">{formatBytes(ds.used || 0)}</td>
|
||||
<td className="px-4 py-2 text-xs">{formatBytes(ds.avail || 0)}</td>
|
||||
<td className="px-4 py-2 text-xs">{ds.mountpoint || "—"}</td>
|
||||
<td className="px-4 py-2 text-xs">{ds.compression || "off"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshots Tab */}
|
||||
{currentPoolTab === "snapshots" && (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
See Snapshots page for detailed snapshot management
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Tab */}
|
||||
{currentPoolTab === "status" && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Health</p>
|
||||
<p className="font-bold">ONLINE</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Mounted</p>
|
||||
<p className="font-bold">Yes</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Record Size</p>
|
||||
<p className="font-bold">128 KiB</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{datasets.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">No datasets found</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shares Tab */}
|
||||
{tab === "shares" && (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-bold mb-4">Samba Shares</h2>
|
||||
<div className="mb-4">
|
||||
<Button onClick={() => setShowCreateSambaShare(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Samba Share
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Path</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Comment</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sambaShares.map((share) => (
|
||||
<tr key={share.name} className="border-b border-border hover:bg-muted/50">
|
||||
<td className="px-4 py-3 font-mono text-xs">{share.name}</td>
|
||||
<td className="px-4 py-3 text-xs">{share.path}</td>
|
||||
<td className="px-4 py-3 text-xs">{share.comment || "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => setDeleteSambaShare(share.name)}
|
||||
className="text-destructive hover:underline"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{sambaShares.length === 0 && (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
No Samba shares
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-4">NFS Shares</h2>
|
||||
<div className="mb-4">
|
||||
<Button onClick={() => setShowCreateNfsShare(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create NFS Share
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">Path</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Clients</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Options</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nfsShares.map((share) => (
|
||||
<tr key={share.path} className="border-b border-border hover:bg-muted/50">
|
||||
<td className="px-4 py-3 text-xs font-mono">{share.path}</td>
|
||||
<td className="px-4 py-3 text-xs">{share.clients}</td>
|
||||
<td className="px-4 py-3 text-xs">{share.options || "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => setDeleteNfsShare(share.path)}
|
||||
className="text-destructive hover:underline"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{nfsShares.length === 0 && (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
No NFS shares
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Dataset Dialog */}
|
||||
<Dialog
|
||||
open={showCreateDataset}
|
||||
onClose={() => setShowCreateDataset(false)}
|
||||
title="Create Dataset"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="dataset name (e.g., tank/data)"
|
||||
value={newDatasetName}
|
||||
onChange={(e) => setNewDatasetName(e.target.value)}
|
||||
className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateDataset} className="flex-1">
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowCreateDataset(false)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Dataset Dialog */}
|
||||
<Dialog
|
||||
open={!!deleteDataset}
|
||||
onClose={() => setDeleteDataset(null)}
|
||||
title="Delete Dataset"
|
||||
>
|
||||
<p className="mb-4 text-sm">
|
||||
Are you sure you want to delete <span className="font-mono">{deleteDataset}</span>?
|
||||
This cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => deleteDataset && handleDeleteDataset(deleteDataset)}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setDeleteDataset(null)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Create Samba Share Dialog */}
|
||||
<Dialog
|
||||
open={showCreateSambaShare}
|
||||
onClose={() => setShowCreateSambaShare(false)}
|
||||
title="Create Samba Share"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="share name"
|
||||
value={newSambaName}
|
||||
onChange={(e) => setNewSambaName(e.target.value)}
|
||||
className="w-full border border-input rounded px-3 py-2 mb-3 bg-background text-foreground text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="path (e.g., /mnt/tank/share)"
|
||||
value={newSambaPath}
|
||||
onChange={(e) => setNewSambaPath(e.target.value)}
|
||||
className="w-full border border-input rounded px-3 py-2 mb-3 bg-background text-foreground text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="comment (optional)"
|
||||
value={newSambaComment}
|
||||
onChange={(e) => setNewSambaComment(e.target.value)}
|
||||
className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateSambaShare} className="flex-1">
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowCreateSambaShare(false)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Samba Share Dialog */}
|
||||
<Dialog
|
||||
open={!!deleteSambaShare}
|
||||
onClose={() => setDeleteSambaShare(null)}
|
||||
title="Delete Samba Share"
|
||||
>
|
||||
<p className="mb-4 text-sm">
|
||||
Are you sure you want to delete <span className="font-mono">{deleteSambaShare}</span>?
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => deleteSambaShare && handleDeleteSambaShare(deleteSambaShare)}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setDeleteSambaShare(null)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Create NFS Share Dialog */}
|
||||
<Dialog
|
||||
open={showCreateNfsShare}
|
||||
onClose={() => setShowCreateNfsShare(false)}
|
||||
title="Create NFS Share"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="path (e.g., /mnt/tank/share)"
|
||||
value={newNfsPath}
|
||||
onChange={(e) => setNewNfsPath(e.target.value)}
|
||||
className="w-full border border-input rounded px-3 py-2 mb-3 bg-background text-foreground text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="clients (e.g., 192.168.1.0/24 or *)"
|
||||
value={newNfsClients}
|
||||
onChange={(e) => setNewNfsClients(e.target.value)}
|
||||
className="w-full border border-input rounded px-3 py-2 mb-3 bg-background text-foreground text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="options"
|
||||
value={newNfsOptions}
|
||||
onChange={(e) => setNewNfsOptions(e.target.value)}
|
||||
className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateNfsShare} className="flex-1">
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowCreateNfsShare(false)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete NFS Share Dialog */}
|
||||
<Dialog
|
||||
open={!!deleteNfsShare}
|
||||
onClose={() => setDeleteNfsShare(null)}
|
||||
title="Delete NFS Share"
|
||||
>
|
||||
<p className="mb-4 text-sm">
|
||||
Are you sure you want to delete <span className="font-mono">{deleteNfsShare}</span>?
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => deleteNfsShare && handleDeleteNfsShare(deleteNfsShare)}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setDeleteNfsShare(null)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { api } from "@/lib/api"
|
||||
import { Header } from "@/components/Header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AlertCircle, RefreshCw } from "lucide-react"
|
||||
|
||||
export default function FileSharingPage() {
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState<"samba" | "nfs">("samba")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
// Samba state
|
||||
const [sambaConfig, setSambaConfig] = useState<string>("")
|
||||
const [sambaConfigOriginal, setSambaConfigOriginal] = useState<string>("")
|
||||
const [sambaEditing, setSambaEditing] = useState(false)
|
||||
|
||||
// NFS state
|
||||
const [nfsConfig, setNfsConfig] = useState<string>("")
|
||||
const [nfsConfigOriginal, setNfsConfigOriginal] = useState<string>("")
|
||||
const [nfsEditing, setNfsEditing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (!token) {
|
||||
router.push("/login")
|
||||
return
|
||||
}
|
||||
loadConfigs()
|
||||
}, [router])
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [samba, nfs] = await Promise.all([
|
||||
api.getSambaGlobalConfig().catch(() => ({})),
|
||||
api.getNfsGlobalConfig().catch(() => ({ exports: "" })),
|
||||
])
|
||||
|
||||
const sambaStr = typeof samba === "object" ? JSON.stringify(samba, null, 2) : String(samba)
|
||||
const nfsStr = nfs?.exports || ""
|
||||
|
||||
setSambaConfig(sambaStr)
|
||||
setSambaConfigOriginal(sambaStr)
|
||||
setNfsConfig(nfsStr)
|
||||
setNfsConfigOriginal(nfsStr)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load configurations")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSambaSave = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
await api.setSambaGlobalConfig(sambaConfig)
|
||||
setSambaConfigOriginal(sambaConfig)
|
||||
setSambaEditing(false)
|
||||
setSuccess("Samba configuration saved successfully")
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save Samba configuration")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNfsSave = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
await api.setNfsGlobalConfig(nfsConfig)
|
||||
setNfsConfigOriginal(nfsConfig)
|
||||
setNfsEditing(false)
|
||||
setSuccess("NFS configuration saved successfully")
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save NFS configuration")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
<main className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">File Sharing Configuration</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage Samba (SMB) and NFS global settings</p>
|
||||
</div>
|
||||
<Button onClick={loadConfigs} disabled={loading} variant="outline">
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="mb-6 flex items-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Alert */}
|
||||
{success && (
|
||||
<div className="mb-6 flex items-center gap-3 rounded-md border border-green-600/40 bg-green-500/10 px-4 py-3 text-sm text-green-600">
|
||||
✓ {success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6 border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab("samba")}
|
||||
className={`px-4 py-3 font-medium border-b-2 transition-colors ${
|
||||
activeTab === "samba"
|
||||
? "border-accent text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Samba (SMB) Configuration
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("nfs")}
|
||||
className={`px-4 py-3 font-medium border-b-2 transition-colors ${
|
||||
activeTab === "nfs"
|
||||
? "border-accent text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
NFS Configuration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Samba Tab */}
|
||||
{activeTab === "samba" && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Samba Global Configuration</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{sambaEditing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSambaConfig(sambaConfigOriginal)
|
||||
setSambaEditing(false)
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{sambaEditing ? (
|
||||
<Button onClick={handleSambaSave} disabled={saving || sambaConfig === sambaConfigOriginal}>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setSambaEditing(true)} variant="default">
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sambaEditing ? (
|
||||
<textarea
|
||||
value={sambaConfig}
|
||||
onChange={(e) => setSambaConfig(e.target.value)}
|
||||
className="w-full h-96 p-3 font-mono text-sm bg-background border border-border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={saving}
|
||||
/>
|
||||
) : (
|
||||
<pre className="bg-muted p-4 rounded-md overflow-x-auto text-sm">
|
||||
{sambaConfig || "No Samba configuration available"}
|
||||
</pre>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Edits will be applied to the [global] section of /etc/samba/smb.conf
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* NFS Tab */}
|
||||
{activeTab === "nfs" && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>NFS Export Configuration</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{nfsEditing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setNfsConfig(nfsConfigOriginal)
|
||||
setNfsEditing(false)
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{nfsEditing ? (
|
||||
<Button onClick={handleNfsSave} disabled={saving || nfsConfig === nfsConfigOriginal}>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setNfsEditing(true)} variant="default">
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{nfsEditing ? (
|
||||
<textarea
|
||||
value={nfsConfig}
|
||||
onChange={(e) => setNfsConfig(e.target.value)}
|
||||
className="w-full h-96 p-3 font-mono text-sm bg-background border border-border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={saving}
|
||||
/>
|
||||
) : (
|
||||
<pre className="bg-muted p-4 rounded-md overflow-x-auto text-sm">
|
||||
{nfsConfig || "No NFS exports configured"}
|
||||
</pre>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Edits will be applied to /etc/exports. Format: path client(options)
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Example: /tank/share 192.168.1.0/24(rw,sync,no_subtree_check)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.6%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.6%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.6%;
|
||||
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
--muted: 0 0% 89.7%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
|
||||
--accent: 0 84.2% 60.2%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 0 0% 89.7%;
|
||||
--input: 0 0% 89.7%;
|
||||
--ring: 0 0% 3.6%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.6%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 0 0% 3.6%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 0 0% 3.6%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
|
||||
--accent: 0 84.2% 60.2%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 9%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 84.2% 60.2%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { api, SystemUser, SystemGroup, LoginEntry } from "@/lib/api"
|
||||
import { Header } from "@/components/Header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog } from "@/components/ui/dialog"
|
||||
import { AlertCircle, Plus, Trash2, Lock, Unlock, Key, Terminal, Search as SearchIcon } from "lucide-react"
|
||||
|
||||
export default function IdentitiesPage() {
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState<"users" | "groups" | "history">("users")
|
||||
const [usersSubTab, setUsersSubTab] = useState<"linux" | "samba">("linux")
|
||||
const [users, setUsers] = useState<SystemUser[]>([])
|
||||
const [sambaUsers, setSambaUsers] = useState<SystemUser[]>([])
|
||||
const [groups, setGroups] = useState<SystemGroup[]>([])
|
||||
const [logins, setLogins] = useState<LoginEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedGroupForAdd, setSelectedGroupForAdd] = useState<string | null>(null)
|
||||
const [selectedUserForGroup, setSelectedUserForGroup] = useState<string | null>(null)
|
||||
|
||||
// Dialog states
|
||||
const [createUserDialog, setCreateUserDialog] = useState(false)
|
||||
const [createGroupDialog, setCreateGroupDialog] = useState(false)
|
||||
const [passwordDialog, setPasswordDialog] = useState(false)
|
||||
const [shellDialog, setShellDialog] = useState(false)
|
||||
const [sambaPasswordDialog, setSambaPasswordDialog] = useState(false)
|
||||
const [deleteUserDialog, setDeleteUserDialog] = useState(false)
|
||||
const [deleteGroupDialog, setDeleteGroupDialog] = useState(false)
|
||||
const [addUserToGroupDialog, setAddUserToGroupDialog] = useState(false)
|
||||
|
||||
// Form states
|
||||
const [selectedUser, setSelectedUser] = useState<string | null>(null)
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
|
||||
const [newUsername, setNewUsername] = useState("")
|
||||
const [newHomeDir, setNewHomeDir] = useState("")
|
||||
const [newShell, setNewShell] = useState("/bin/bash")
|
||||
const [newGecos, setNewGecos] = useState("")
|
||||
const [newGroupName, setNewGroupName] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [newShellValue, setNewShellValue] = useState("")
|
||||
const [sambaPassword, setSambaPassword] = useState("")
|
||||
const [removeHomeDir, setRemoveHomeDir] = useState(true)
|
||||
const [loginLimit, setLoginLimit] = useState(50)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (!token) {
|
||||
router.push("/login")
|
||||
return
|
||||
}
|
||||
loadData()
|
||||
}, [router])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const [usersData, sambaUsersData, groupsData, loginsData] = await Promise.all([
|
||||
api.getUsers(),
|
||||
api.getSambaUsers(),
|
||||
api.getGroups(),
|
||||
api.getLoginHistory(loginLimit),
|
||||
])
|
||||
setUsers(usersData)
|
||||
setSambaUsers(sambaUsersData)
|
||||
setGroups(groupsData)
|
||||
setLogins(loginsData)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load data")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!newUsername.trim()) return
|
||||
try {
|
||||
await api.createUser(newUsername, newHomeDir || undefined, newShell, newGecos || undefined)
|
||||
setNewUsername("")
|
||||
setNewHomeDir("")
|
||||
setNewShell("/bin/bash")
|
||||
setNewGecos("")
|
||||
setCreateUserDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create user")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!selectedUser) return
|
||||
try {
|
||||
await api.deleteUser(selectedUser, removeHomeDir)
|
||||
setSelectedUser(null)
|
||||
setDeleteUserDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete user")
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!selectedUser || !newPassword.trim()) return
|
||||
try {
|
||||
await api.changePassword(selectedUser, newPassword)
|
||||
setSelectedUser(null)
|
||||
setNewPassword("")
|
||||
setPasswordDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to change password")
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeShell = async () => {
|
||||
if (!selectedUser || !newShellValue.trim()) return
|
||||
try {
|
||||
await api.changeShell(selectedUser, newShellValue)
|
||||
setSelectedUser(null)
|
||||
setNewShellValue("")
|
||||
setShellDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to change shell")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetSambaPassword = async () => {
|
||||
if (!selectedUser || !sambaPassword.trim()) return
|
||||
try {
|
||||
await api.setSambaPassword(selectedUser, sambaPassword)
|
||||
setSelectedUser(null)
|
||||
setSambaPassword("")
|
||||
setSambaPasswordDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to set Samba password")
|
||||
}
|
||||
}
|
||||
|
||||
const handleLockUser = async (username: string) => {
|
||||
try {
|
||||
await api.lockUser(username)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to lock user")
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlockUser = async (username: string) => {
|
||||
try {
|
||||
await api.unlockUser(username)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to unlock user")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
if (!newGroupName.trim()) return
|
||||
try {
|
||||
await api.createGroup(newGroupName)
|
||||
setNewGroupName("")
|
||||
setCreateGroupDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create group")
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddUserToGroup = async () => {
|
||||
if (!selectedUserForGroup || !selectedGroupForAdd) return
|
||||
try {
|
||||
await api.addUserToGroup(selectedUserForGroup, selectedGroupForAdd)
|
||||
setSelectedUserForGroup(null)
|
||||
setSelectedGroupForAdd(null)
|
||||
setAddUserToGroupDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add user to group")
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveUserFromGroup = async (username: string, groupname: string) => {
|
||||
try {
|
||||
await api.removeUserFromGroup(username, groupname)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to remove user from group")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteGroup = async () => {
|
||||
if (!selectedGroup) return
|
||||
try {
|
||||
await api.deleteGroup(selectedGroup)
|
||||
setSelectedGroup(null)
|
||||
setDeleteGroupDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete group")
|
||||
}
|
||||
}
|
||||
|
||||
// Filter users and groups based on search
|
||||
const filteredUsers = users.filter(u =>
|
||||
u.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
u.gecos?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
const filteredSambaUsers = sambaUsers.filter(u =>
|
||||
u.username.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
const filteredGroups = groups.filter(g =>
|
||||
g.groupname.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Identities</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage users, groups, and view login history</p>
|
||||
</div>
|
||||
<Button onClick={loadData} variant="outline" size="sm">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<Card className="mb-6 border-red-200 bg-red-50">
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-2 mb-6 border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab("users")}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
activeTab === "users"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Users ({users.length + sambaUsers.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("groups")}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
activeTab === "groups"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Groups ({groups.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("history")}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
activeTab === "history"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Login History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar (for Users and Groups tabs) */}
|
||||
{(activeTab === "users" || activeTab === "groups") && (
|
||||
<div className="mb-6">
|
||||
<div className="flex gap-2">
|
||||
<SearchIcon className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={activeTab === "users" ? "Search users..." : "Search groups..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-border rounded bg-background text-foreground text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
onClick={() => setSearchQuery("")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* USERS TAB */}
|
||||
{activeTab === "users" && (
|
||||
<div>
|
||||
{/* Sub-tabs for Linux vs Samba users */}
|
||||
<div className="flex gap-2 mb-4 border-b border-border">
|
||||
<button
|
||||
onClick={() => setUsersSubTab("linux")}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
usersSubTab === "linux"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Linux Users ({users.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUsersSubTab("samba")}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
usersSubTab === "samba"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Samba Users ({sambaUsers.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LINUX USERS */}
|
||||
{usersSubTab === "linux" && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Button onClick={() => setCreateUserDialog(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">Loading users...</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">System Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">Username</th>
|
||||
<th className="text-left py-3 px-4 font-medium">UID</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Home</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Shell</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Groups</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Status</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.username} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 font-mono text-xs">{user.username}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{user.uid}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{user.home}</td>
|
||||
<td className="py-3 px-4 text-xs font-mono">{user.shell}</td>
|
||||
<td className="py-3 px-4 text-xs">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{user.groups.map((g) => (
|
||||
<Badge key={g} variant="secondary" className="text-xs">
|
||||
{g}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs">
|
||||
{user.locked ? (
|
||||
<Badge variant="destructive">Locked</Badge>
|
||||
) : (
|
||||
<Badge variant="success">Active</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs space-x-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username)
|
||||
setPasswordDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-muted"
|
||||
title="Change password"
|
||||
>
|
||||
<Key className="w-3 h-3 inline" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username)
|
||||
setNewShellValue(user.shell)
|
||||
setShellDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-muted"
|
||||
title="Change shell"
|
||||
>
|
||||
<Terminal className="w-3 h-3 inline" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username)
|
||||
setSambaPassword("")
|
||||
setSambaPasswordDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-muted"
|
||||
title="Set Samba password"
|
||||
>
|
||||
<Key className="w-3 h-3 inline" style={{ opacity: 0.6 }} />
|
||||
</button>
|
||||
{user.locked ? (
|
||||
<button
|
||||
onClick={() => handleUnlockUser(user.username)}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-muted text-green-600"
|
||||
title="Unlock"
|
||||
>
|
||||
<Unlock className="w-3 h-3 inline" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleLockUser(user.username)}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-muted text-amber-600"
|
||||
title="Lock"
|
||||
>
|
||||
<Lock className="w-3 h-3 inline" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username)
|
||||
setDeleteUserDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-destructive/50 hover:bg-destructive/10 text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 inline" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SAMBA USERS */}
|
||||
{usersSubTab === "samba" && (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">Loading Samba users...</div>
|
||||
) : sambaUsers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No Samba users found. Install and configure Samba to see users here.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Samba Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">Username</th>
|
||||
<th className="text-left py-3 px-4 font-medium">UID</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSambaUsers.map((user) => (
|
||||
<tr key={user.username} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 font-mono text-xs">
|
||||
<Badge variant="outline">{user.username}</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{user.uid}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{(user as any).comment || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GROUPS TAB */}
|
||||
{activeTab === "groups" && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Button onClick={() => setCreateGroupDialog(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">Loading groups...</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">System Groups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">Group Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium">GID</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Members</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredGroups.map((group) => (
|
||||
<tr key={group.groupname} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 font-mono text-xs">{group.groupname}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{group.gid}</td>
|
||||
<td className="py-3 px-4 text-xs">
|
||||
<div className="flex gap-1 flex-wrap items-center">
|
||||
{group.members && group.members.length > 0 ? (
|
||||
group.members.map((m) => (
|
||||
<div key={m} className="flex items-center gap-1 bg-secondary rounded px-2 py-1">
|
||||
<span>{m}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveUserFromGroup(m, group.groupname)}
|
||||
className="text-xs hover:text-destructive"
|
||||
title="Remove from group"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">(empty)</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedGroupForAdd(group.groupname)
|
||||
setAddUserToGroupDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-primary/50 hover:bg-primary/10 text-primary text-xs"
|
||||
title="Add user to group"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedGroup(group.groupname)
|
||||
setDeleteGroupDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-destructive/50 hover:bg-destructive/10 text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 inline" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LOGIN HISTORY TAB */}
|
||||
{activeTab === "history" && (
|
||||
<div>
|
||||
<div className="mb-4 flex gap-2 items-center">
|
||||
<label className="text-sm text-muted-foreground">Limit:</label>
|
||||
<select
|
||||
value={loginLimit}
|
||||
onChange={(e) => {
|
||||
setLoginLimit(parseInt(e.target.value))
|
||||
loadData()
|
||||
}}
|
||||
className="px-3 py-1 text-sm border border-border rounded bg-background"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">Loading login history...</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Recent Logins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{logins.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">No login history found</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">User</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Terminal</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Host/IP</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Login Time</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Logout Time</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logins.map((login, idx) => (
|
||||
<tr key={idx} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 font-mono text-xs">{(login as any).username || (login as any).user}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{(login as any).tty || (login as any).terminal || "—"}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{(login as any).host || "—"}</td>
|
||||
<td className="py-3 px-4 text-xs">{(login as any).login_str || (login as any).login_time || "—"}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">
|
||||
{(login as any).logout_time || "—"}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{(login as any).duration || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Create User Dialog */}
|
||||
<Dialog open={createUserDialog} onClose={() => setCreateUserDialog(false)} title="Create User">
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Home directory (optional)"
|
||||
value={newHomeDir}
|
||||
onChange={(e) => setNewHomeDir(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
/>
|
||||
<select
|
||||
value={newShell}
|
||||
onChange={(e) => setNewShell(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
>
|
||||
<option>/bin/bash</option>
|
||||
<option>/bin/sh</option>
|
||||
<option>/sbin/nologin</option>
|
||||
<option>/usr/sbin/nologin</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Full name (optional)"
|
||||
value={newGecos}
|
||||
onChange={(e) => setNewGecos(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateUser} className="flex-1">
|
||||
Create
|
||||
</Button>
|
||||
<Button onClick={() => setCreateUserDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Create Group Dialog */}
|
||||
<Dialog open={createGroupDialog} onClose={() => setCreateGroupDialog(false)} title="Create Group">
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Group name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateGroup} className="flex-1">
|
||||
Create
|
||||
</Button>
|
||||
<Button onClick={() => setCreateGroupDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Change Password Dialog */}
|
||||
<Dialog open={passwordDialog} onClose={() => setPasswordDialog(false)} title={`Change Password for ${selectedUser}`}>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleChangePassword} className="flex-1">
|
||||
Change
|
||||
</Button>
|
||||
<Button onClick={() => setPasswordDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Change Shell Dialog */}
|
||||
<Dialog open={shellDialog} onClose={() => setShellDialog(false)} title={`Change Shell for ${selectedUser}`}>
|
||||
<div className="space-y-4">
|
||||
<select
|
||||
value={newShellValue}
|
||||
onChange={(e) => setNewShellValue(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
>
|
||||
<option>/bin/bash</option>
|
||||
<option>/bin/sh</option>
|
||||
<option>/sbin/nologin</option>
|
||||
<option>/usr/sbin/nologin</option>
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleChangeShell} className="flex-1">
|
||||
Change
|
||||
</Button>
|
||||
<Button onClick={() => setShellDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Set Samba Password Dialog */}
|
||||
<Dialog open={sambaPasswordDialog} onClose={() => setSambaPasswordDialog(false)} title={`Set Samba Password for ${selectedUser}`}>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New Samba password"
|
||||
value={sambaPassword}
|
||||
onChange={(e) => setSambaPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSetSambaPassword} className="flex-1">
|
||||
Set Password
|
||||
</Button>
|
||||
<Button onClick={() => setSambaPasswordDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete User Dialog */}
|
||||
<Dialog open={deleteUserDialog} onClose={() => setDeleteUserDialog(false)} title="Delete User">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm">Are you sure you want to delete user <span className="font-mono">{selectedUser}</span>?</p>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeHomeDir}
|
||||
onChange={(e) => setRemoveHomeDir(e.target.checked)}
|
||||
className="rounded border border-border"
|
||||
/>
|
||||
<span className="text-sm">Remove home directory</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleDeleteUser} variant="destructive" className="flex-1">
|
||||
Delete
|
||||
</Button>
|
||||
<Button onClick={() => setDeleteUserDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Group Dialog */}
|
||||
<Dialog open={deleteGroupDialog} onClose={() => setDeleteGroupDialog(false)} title="Delete Group">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm">Are you sure you want to delete group <span className="font-mono">{selectedGroup}</span>?</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleDeleteGroup} variant="destructive" className="flex-1">
|
||||
Delete
|
||||
</Button>
|
||||
<Button onClick={() => setDeleteGroupDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Add User to Group Dialog */}
|
||||
<Dialog open={addUserToGroupDialog} onClose={() => setAddUserToGroupDialog(false)} title="Add User to Group">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">Select User</label>
|
||||
<select
|
||||
value={selectedUserForGroup || ""}
|
||||
onChange={(e) => setSelectedUserForGroup(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="">Choose a user...</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.username} value={u.username}>
|
||||
{u.username} (uid {u.uid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleAddUserToGroup}
|
||||
disabled={!selectedUserForGroup || !selectedGroupForAdd}
|
||||
className="flex-1"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAddUserToGroupDialog(false)
|
||||
setSelectedUserForGroup(null)
|
||||
setSelectedGroupForAdd(null)
|
||||
}}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "ZMB Webui",
|
||||
description: "ZFS Storage Management Web UI",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { api } from "@/lib/api"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HardDrive, AlertCircle } from "lucide-react"
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await api.login(username, password)
|
||||
router.push("/")
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Login failed. Please check your credentials."
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 to-slate-800 p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<HardDrive className="w-8 h-8 text-primary" />
|
||||
<h1 className="text-2xl font-bold text-white">ZMB Webui</h1>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sign In</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your credentials to access the ZMB Webui
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md flex gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="text-sm font-medium">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || !username || !password}
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Help Text */}
|
||||
<p className="text-xs text-muted-foreground text-center mt-4">
|
||||
Use your Samba credentials
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-sm text-slate-400 mt-6">
|
||||
ZMB Webui v1.0.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useMemo } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { api } from "@/lib/api"
|
||||
import { Header } from "@/components/Header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { RefreshCw, Filter, X } from "lucide-react"
|
||||
|
||||
type LogEntry = {
|
||||
text: string
|
||||
date?: Date
|
||||
unit?: string
|
||||
level?: "err" | "warning" | "info" | "debug"
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const router = useRouter()
|
||||
const [allLogs, setAllLogs] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [limit, setLimit] = useState(500)
|
||||
|
||||
// Filter states
|
||||
const [timeRange, setTimeRange] = useState("all") // all, 24h, 7d, 30d
|
||||
const [priority, setPriority] = useState("all") // all, err (error and higher)
|
||||
const [unit, setUnit] = useState("") // Unit/Service filter
|
||||
const [searchText, setSearchText] = useState("") // Free text search
|
||||
const [units, setUnits] = useState<string[]>([]) // Available units for dropdown
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (!token) {
|
||||
router.push("/login")
|
||||
return
|
||||
}
|
||||
|
||||
// Load logs
|
||||
loadLogs()
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
// Reload when limit changes
|
||||
loadLogs()
|
||||
}, [limit])
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.getSystemLogs(limit)
|
||||
const logsList = data?.logs || []
|
||||
setAllLogs(logsList)
|
||||
|
||||
// Extract unique units for dropdown
|
||||
const uniqueUnits = new Set<string>()
|
||||
logsList.forEach((log: string) => {
|
||||
const match = log.match(/\s(\S+)\[(\d+)\]:|systemd(\[[\d.]+\])?:|(\S+):/)
|
||||
if (match) {
|
||||
const unitName = match[1] || match[3] || match[4] || ""
|
||||
if (unitName && unitName !== "kernel") {
|
||||
uniqueUnits.add(unitName)
|
||||
}
|
||||
}
|
||||
})
|
||||
setUnits(Array.from(uniqueUnits).sort())
|
||||
} catch (error) {
|
||||
console.error("Failed to load logs:", error)
|
||||
setAllLogs([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse log entry to extract metadata
|
||||
const parseLogEntry = (logText: string): LogEntry => {
|
||||
const entry: LogEntry = { text: logText }
|
||||
|
||||
// Try to parse date from log (format: "MMM DD HH:MM:SS")
|
||||
const dateMatch = logText.match(/^(\w+\s+\d+\s+\d{2}:\d{2}:\d{2})/)
|
||||
if (dateMatch) {
|
||||
try {
|
||||
const now = new Date()
|
||||
const dateStr = `${dateMatch[1]} ${now.getFullYear()}`
|
||||
const parsed = new Date(dateStr)
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
entry.date = parsed
|
||||
}
|
||||
} catch (e) {
|
||||
// Date parsing failed, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Extract unit/service name
|
||||
const unitMatch = logText.match(/\s(\S+?)\[(\d+)\]:|systemd(\[[\d.]+\])?:|(\S+):/)
|
||||
if (unitMatch) {
|
||||
entry.unit = unitMatch[1] || unitMatch[3] || unitMatch[4] || ""
|
||||
}
|
||||
|
||||
// Detect priority level
|
||||
if (logText.match(/ERROR|err|Err|ERR|error/i)) {
|
||||
entry.level = "err"
|
||||
} else if (logText.match(/WARN|warn|WARNING/i)) {
|
||||
entry.level = "warning"
|
||||
} else if (logText.match(/INFO|info|Notice|NOTICE/i)) {
|
||||
entry.level = "info"
|
||||
} else {
|
||||
entry.level = "debug"
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
// Filter logs based on selected criteria
|
||||
const filteredLogs = useMemo(() => {
|
||||
return allLogs.filter((logText) => {
|
||||
const entry = parseLogEntry(logText)
|
||||
|
||||
// Time filter
|
||||
if (timeRange !== "all" && entry.date) {
|
||||
let cutoffDate = new Date()
|
||||
switch (timeRange) {
|
||||
case "24h":
|
||||
cutoffDate.setHours(cutoffDate.getHours() - 24)
|
||||
break
|
||||
case "7d":
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 7)
|
||||
break
|
||||
case "30d":
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 30)
|
||||
break
|
||||
}
|
||||
if (entry.date < cutoffDate) return false
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (priority === "err") {
|
||||
if (entry.level !== "err") return false
|
||||
}
|
||||
|
||||
// Unit filter
|
||||
if (unit && entry.unit) {
|
||||
if (!entry.unit.toLowerCase().includes(unit.toLowerCase())) return false
|
||||
}
|
||||
|
||||
// Text search filter
|
||||
if (searchText) {
|
||||
if (!logText.toLowerCase().includes(searchText.toLowerCase())) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [allLogs, timeRange, priority, unit, searchText])
|
||||
|
||||
const hasActiveFilters = timeRange !== "all" || priority !== "all" || unit || searchText
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">System Logs</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Showing {filteredLogs.length} of {allLogs.length} entries
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(Number(e.target.value))}
|
||||
className="px-3 py-2 rounded-md border border-border bg-background text-sm"
|
||||
>
|
||||
<option value={100}>Last 100</option>
|
||||
<option value={200}>Last 200</option>
|
||||
<option value={500}>Last 500</option>
|
||||
<option value={1000}>Last 1000</option>
|
||||
</select>
|
||||
<Button onClick={loadLogs} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Section */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Time Range Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Letzte</label>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="24h">24 Stunden</option>
|
||||
<option value="7d">7 Tage</option>
|
||||
<option value="30d">30 Tage</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Priorität</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="err">Fehler und höher</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Unit Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Kennung</label>
|
||||
<select
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
||||
>
|
||||
<option value="">Alle Services</option>
|
||||
{units.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Free Text Search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Filter</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. priority:err"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
{hasActiveFilters && (
|
||||
<div className="mt-4 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Filter className="w-4 h-4" /> Aktive Filter:
|
||||
</span>
|
||||
{timeRange !== "all" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-primary/10 text-primary text-xs">
|
||||
{timeRange === "24h"
|
||||
? "Letzte 24h"
|
||||
: timeRange === "7d"
|
||||
? "Letzte 7 Tage"
|
||||
: "Letzte 30 Tage"}
|
||||
<button
|
||||
onClick={() => setTimeRange("all")}
|
||||
className="hover:text-primary/70"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{priority === "err" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-red-500/10 text-red-600 text-xs">
|
||||
Nur Fehler
|
||||
<button
|
||||
onClick={() => setPriority("all")}
|
||||
className="hover:text-red-600/70"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{unit && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-500/10 text-blue-600 text-xs">
|
||||
{unit}
|
||||
<button onClick={() => setUnit("")} className="hover:text-blue-600/70">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{searchText && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-purple-500/10 text-purple-600 text-xs">
|
||||
"{searchText}"
|
||||
<button
|
||||
onClick={() => setSearchText("")}
|
||||
className="hover:text-purple-600/70"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Logs Display */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="bg-muted/30 rounded p-4 font-mono text-xs space-y-1 max-h-[calc(100vh-300px)] overflow-y-auto">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="text-muted-foreground text-center py-8">
|
||||
{loading ? "Loading logs..." : "No logs found matching filters"}
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map((log: string, idx: number) => {
|
||||
const entry = parseLogEntry(log)
|
||||
const bgColor =
|
||||
entry.level === "err"
|
||||
? "bg-red-500/5 hover:bg-red-500/10"
|
||||
: entry.level === "warning"
|
||||
? "bg-yellow-500/5 hover:bg-yellow-500/10"
|
||||
: "hover:bg-muted/50"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`text-muted-foreground px-2 py-1 rounded transition-colors ${bgColor}`}
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,597 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { api, Pool } from "@/lib/api"
|
||||
import { Header } from "@/components/Header"
|
||||
import { PoolCard } from "@/components/PoolCard"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { RefreshCw, AlertCircle, Cpu, HardDrive, Zap, Clock, Network, Database } from "lucide-react"
|
||||
|
||||
export default function Dashboard() {
|
||||
const router = useRouter()
|
||||
const [pools, setPools] = useState<Pool[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null)
|
||||
const [zfsAvailable, setZfsAvailable] = useState<boolean | null>(null)
|
||||
const [systemInfo, setSystemInfo] = useState<any>(null)
|
||||
const [memoryInfo, setMemoryInfo] = useState<any>(null)
|
||||
const [cpuInfo, setCpuInfo] = useState<any>(null)
|
||||
const [uptimeInfo, setUptimeInfo] = useState<any>(null)
|
||||
const [networkInfo, setNetworkInfo] = useState<any>(null)
|
||||
const [networkTraffic, setNetworkTraffic] = useState<any>(null)
|
||||
const [diskIO, setDiskIO] = useState<any>(null)
|
||||
|
||||
// History buffers for sparklines (rolling window of 30 points, ~2.5 minutes at 5s intervals)
|
||||
const cpuHistoryRef = useRef<number[]>([])
|
||||
const memoryHistoryRef = useRef<number[]>([])
|
||||
const networkTrafficHistoryRef = useRef<Map<string, number[]>>(new Map())
|
||||
const [cpuHistory, setCpuHistory] = useState<number[]>([])
|
||||
const [memoryHistory, setMemoryHistory] = useState<number[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (!token) {
|
||||
router.push("/login")
|
||||
return
|
||||
}
|
||||
|
||||
// Load data if authenticated
|
||||
const init = async () => {
|
||||
await checkZfsStatus()
|
||||
await fetchPools()
|
||||
await loadSystemStats()
|
||||
const interval = setInterval(fetchPools, 30000) // Refresh every 30 seconds
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
init()
|
||||
}, [router])
|
||||
|
||||
const checkZfsStatus = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/status")
|
||||
const data = await response.json()
|
||||
setZfsAvailable(data.zfs_available ?? false)
|
||||
return data.zfs_available ?? false
|
||||
} catch (err) {
|
||||
console.error("Failed to check ZFS status:", err)
|
||||
setZfsAvailable(false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B"
|
||||
const k = 1024
|
||||
const sizes = ["B", "KB", "MB", "GB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i]
|
||||
}
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const parts = []
|
||||
if (days > 0) parts.push(`${days} day${days > 1 ? 's' : ''}`)
|
||||
if (hours > 0) parts.push(`${hours} hr${hours > 1 ? 's' : ''}`)
|
||||
if (minutes > 0 || parts.length === 0) parts.push(`${minutes} min`)
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
const formatBootTime = (timestamp: number) => {
|
||||
try {
|
||||
return new Date(timestamp * 1000).toLocaleString()
|
||||
} catch {
|
||||
return 'N/A'
|
||||
}
|
||||
}
|
||||
|
||||
// Sparkline helper: convert array of 0-100 values to SVG polyline points
|
||||
const sparklinePoints = (data: number[], width = 120, height = 32): string => {
|
||||
if (data.length < 2) return ""
|
||||
const step = width / (data.length - 1)
|
||||
return data.map((v, i) => `${i * step},${height - Math.max(0, Math.min(100, v)) / 100 * height}`).join(" ")
|
||||
}
|
||||
|
||||
const loadSystemStats = async () => {
|
||||
try {
|
||||
const [sysInfo, memInfo, cpuData, uptime, network, traffic, diskio] = await Promise.all([
|
||||
api.getSystemInfo().catch(() => null),
|
||||
api.getMemory().catch(() => null),
|
||||
api.getCpuInfo().catch(() => null),
|
||||
api.getUptime().catch(() => null),
|
||||
api.getNetwork().catch(() => null),
|
||||
api.getNetworkTraffic().catch(() => null),
|
||||
api.getDiskIO().catch(() => null),
|
||||
])
|
||||
setSystemInfo(sysInfo)
|
||||
setMemoryInfo(memInfo)
|
||||
setCpuInfo(cpuData)
|
||||
setUptimeInfo(uptime)
|
||||
setNetworkInfo(network)
|
||||
setNetworkTraffic(traffic)
|
||||
setDiskIO(diskio)
|
||||
|
||||
// Add to history
|
||||
if (cpuData?.percent !== undefined) {
|
||||
const newCpuHistory = [...cpuHistoryRef.current, cpuData.percent]
|
||||
if (newCpuHistory.length > 30) newCpuHistory.shift()
|
||||
cpuHistoryRef.current = newCpuHistory
|
||||
setCpuHistory(newCpuHistory)
|
||||
}
|
||||
if (memInfo?.total && memInfo?.used !== undefined) {
|
||||
const memPercent = (memInfo.used / memInfo.total) * 100
|
||||
const newMemHistory = [...memoryHistoryRef.current, memPercent]
|
||||
if (newMemHistory.length > 30) newMemHistory.shift()
|
||||
memoryHistoryRef.current = newMemHistory
|
||||
setMemoryHistory(newMemHistory)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load system stats:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic update for history every 5 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const [cpuData, memInfo, traffic, diskio] = await Promise.all([
|
||||
api.getCpuInfo().catch(() => null),
|
||||
api.getMemory().catch(() => null),
|
||||
api.getNetworkTraffic().catch(() => null),
|
||||
api.getDiskIO().catch(() => null),
|
||||
])
|
||||
|
||||
if (cpuData?.percent !== undefined) {
|
||||
const newCpuHistory = [...cpuHistoryRef.current, cpuData.percent]
|
||||
if (newCpuHistory.length > 30) newCpuHistory.shift()
|
||||
cpuHistoryRef.current = newCpuHistory
|
||||
setCpuHistory(newCpuHistory)
|
||||
}
|
||||
if (memInfo?.total && memInfo?.used !== undefined) {
|
||||
const memPercent = (memInfo.used / memInfo.total) * 100
|
||||
const newMemHistory = [...memoryHistoryRef.current, memPercent]
|
||||
if (newMemHistory.length > 30) newMemHistory.shift()
|
||||
memoryHistoryRef.current = newMemHistory
|
||||
setMemoryHistory(newMemHistory)
|
||||
}
|
||||
if (traffic?.interfaces) {
|
||||
setNetworkTraffic(traffic)
|
||||
}
|
||||
if (diskio?.disks) {
|
||||
setDiskIO(diskio)
|
||||
}
|
||||
|
||||
if (traffic?.interfaces) {
|
||||
const newHistory = new Map(networkTrafficHistoryRef.current)
|
||||
for (const iface of traffic.interfaces) {
|
||||
if (iface.name === 'lo') continue // Skip loopback
|
||||
const key = `${iface.name}_rx`
|
||||
const current = newHistory.get(key) || []
|
||||
const updated = [...current, iface.rx_bytes]
|
||||
if (updated.length > 30) updated.shift()
|
||||
newHistory.set(key, updated)
|
||||
}
|
||||
networkTrafficHistoryRef.current = newHistory
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently fail
|
||||
}
|
||||
}, 5000) // Every 5 seconds
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const fetchPools = async () => {
|
||||
// If ZFS is not available, don't try to fetch pools
|
||||
if (zfsAvailable === false) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await api.getPools()
|
||||
setPools(data)
|
||||
setLastUpdate(new Date())
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch pools"
|
||||
setError(message)
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{lastUpdate ? `Last updated: ${lastUpdate.toLocaleTimeString()}` : "Loading..."}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={fetchPools} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats - System Metrics (Phase 3a) */}
|
||||
{(systemInfo || memoryInfo || cpuInfo || uptimeInfo) && (
|
||||
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Hostname & Uptime */}
|
||||
{systemInfo && uptimeInfo && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
System
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-lg font-bold truncate">{systemInfo.hostname}</p>
|
||||
<p className="text-xs text-muted-foreground">Uptime: {uptimeInfo.uptime_string}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{systemInfo.kernel}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* CPU Usage with Sparkline */}
|
||||
{cpuInfo && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
CPU
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-bold">{cpuInfo.percent !== undefined ? cpuInfo.percent.toFixed(1) : "N/A"}%</p>
|
||||
{cpuHistory.length > 1 && (
|
||||
<svg width="100%" height="32" viewBox="0 0 120 32" preserveAspectRatio="none" className="w-full h-8">
|
||||
<polyline
|
||||
points={sparklinePoints(cpuHistory, 120, 32)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-primary"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Load: {cpuInfo.load_average?.[0]?.toFixed(2)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Memory Usage with Sparkline */}
|
||||
{memoryInfo && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4" />
|
||||
Memory
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-bold">
|
||||
{((memoryInfo.used / memoryInfo.total) * 100).toFixed(1)}%
|
||||
</p>
|
||||
{memoryHistory.length > 1 && (
|
||||
<svg width="100%" height="32" viewBox="0 0 120 32" preserveAspectRatio="none" className="w-full h-8">
|
||||
<polyline
|
||||
points={sparklinePoints(memoryHistory, 120, 32)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-primary"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatBytes(memoryInfo.used)} / {formatBytes(memoryInfo.total)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Uptime */}
|
||||
{uptimeInfo && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
System Uptime
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Uptime</p>
|
||||
<p className="text-sm font-semibold">{formatUptime(uptimeInfo.uptime_seconds || 0)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Booted</p>
|
||||
<p className="text-xs font-mono">{formatBootTime(uptimeInfo.boot_time || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Disk Usage */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4" />
|
||||
Storage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{zfsAvailable ? (
|
||||
<div>
|
||||
<p className="text-lg font-bold">ZFS</p>
|
||||
<p className="text-xs text-muted-foreground">View pools below</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-lg font-bold">N/A</p>
|
||||
<p className="text-xs text-muted-foreground">ZFS not available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Details Card */}
|
||||
{systemInfo && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Systeminformationen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{systemInfo.model && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Modell</p>
|
||||
<p className="text-base font-semibold mt-1">{systemInfo.model}</p>
|
||||
</div>
|
||||
)}
|
||||
{systemInfo.machine_id && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Maschinen-ID</p>
|
||||
<p className="text-base font-mono text-xs mt-1 break-all">
|
||||
{systemInfo.machine_id}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{systemInfo.processor && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Prozessor</p>
|
||||
<p className="text-base font-semibold mt-1 line-clamp-2">
|
||||
{systemInfo.processor}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{systemInfo.kernel && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Kernel</p>
|
||||
<p className="text-base font-semibold mt-1">{systemInfo.kernel}</p>
|
||||
</div>
|
||||
)}
|
||||
{systemInfo.system && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Betriebssystem</p>
|
||||
<p className="text-base font-semibold mt-1">{systemInfo.system}</p>
|
||||
</div>
|
||||
)}
|
||||
{systemInfo.domain && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Domain</p>
|
||||
<p className="text-base font-semibold mt-1">{systemInfo.domain}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && zfsAvailable !== false && (
|
||||
<Card className="mb-6 border-red-200 bg-red-50">
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-red-900">Error</p>
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && pools.length === 0 && zfsAvailable !== false && (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin">
|
||||
<RefreshCw className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-4 text-muted-foreground">Loading pools...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Network Interfaces */}
|
||||
{networkInfo?.interfaces && networkInfo.interfaces.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Network Interfaces</h2>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">Interface</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Status</th>
|
||||
<th className="text-left py-3 px-4 font-medium">IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{networkInfo.interfaces.map((iface: any) => (
|
||||
<tr key={iface.name} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 font-mono text-xs">{iface.name}</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant={iface.state === "UP" ? "default" : "secondary"}>
|
||||
{iface.state}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs">
|
||||
{iface.addresses && iface.addresses.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{iface.addresses.map((addr: any, idx: number) => (
|
||||
<div key={idx}>{addr.local}</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Network Traffic */}
|
||||
{networkTraffic?.interfaces && networkTraffic.interfaces.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Network Traffic</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{networkTraffic.interfaces
|
||||
.filter((iface: any) => iface.name !== 'lo') // Skip loopback
|
||||
.map((iface: any) => (
|
||||
<Card key={`${iface.name}_traffic`}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Network className="w-4 h-4" />
|
||||
{iface.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">RX</p>
|
||||
<p className="text-sm font-semibold">{formatBytes(iface.rx_bytes)}</p>
|
||||
<p className="text-xs text-muted-foreground">{iface.rx_packets.toLocaleString()} packets</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">TX</p>
|
||||
<p className="text-sm font-semibold">{formatBytes(iface.tx_bytes)}</p>
|
||||
<p className="text-xs text-muted-foreground">{iface.tx_packets.toLocaleString()} packets</p>
|
||||
</div>
|
||||
{(iface.rx_drops > 0 || iface.tx_drops > 0) && (
|
||||
<div className="pt-2 border-t border-border/30">
|
||||
<p className="text-xs text-amber-600">
|
||||
⚠ {iface.rx_drops + iface.tx_drops} dropped packets
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disk I/O */}
|
||||
{diskIO?.disks && diskIO.disks.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Disk I/O</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{diskIO.disks.map((disk: any) => (
|
||||
<Card key={`${disk.name}_io`}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
{disk.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Reads</p>
|
||||
<p className="text-sm font-semibold">{disk.reads_completed.toLocaleString()} ops</p>
|
||||
<p className="text-xs text-muted-foreground">{formatBytes(disk.reads_bytes)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Writes</p>
|
||||
<p className="text-sm font-semibold">{disk.writes_completed.toLocaleString()} ops</p>
|
||||
<p className="text-xs text-muted-foreground">{formatBytes(disk.writes_bytes)}</p>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-border/30">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total: {formatBytes(disk.reads_bytes + disk.writes_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pools Grid */}
|
||||
{!loading && pools.length > 0 && zfsAvailable !== false && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Storage Pools ({pools.length})</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pools.map((pool) => (
|
||||
<PoolCard
|
||||
key={pool.name}
|
||||
pool={pool}
|
||||
onClick={() => router.push(`/pools/${pool.name}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && pools.length === 0 && !error && zfsAvailable !== false && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No Pools Found</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
No ZFS pools are available on this system. Create a new pool to get started.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useMemo } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { api } from "@/lib/api"
|
||||
import { Header } from "@/components/Header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { RefreshCw, X, Zap, Target, Wifi, Clock, FolderOpen } from "lucide-react"
|
||||
|
||||
type Unit = {
|
||||
name: string
|
||||
active: string
|
||||
sub: string
|
||||
description: string
|
||||
}
|
||||
|
||||
type UnitType = "services" | "targets" | "sockets" | "timers" | "paths"
|
||||
|
||||
export default function Services() {
|
||||
const router = useRouter()
|
||||
const [units, setUnits] = useState<Record<UnitType, Unit[]>>({
|
||||
services: [],
|
||||
targets: [],
|
||||
sockets: [],
|
||||
timers: [],
|
||||
paths: [],
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Filter states
|
||||
const [activeTab, setActiveTab] = useState<UnitType>("services")
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [activeStatus, setActiveStatus] = useState("all") // all, active, inactive
|
||||
const [fileStatus, setFileStatus] = useState("all") // all, enabled, disabled, static
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (!token) {
|
||||
router.push("/login")
|
||||
return
|
||||
}
|
||||
|
||||
loadUnits()
|
||||
}, [router])
|
||||
|
||||
const loadUnits = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.getUnits()
|
||||
setUnits(data)
|
||||
} catch (error) {
|
||||
console.error("Failed to load units:", error)
|
||||
setUnits({
|
||||
services: [],
|
||||
targets: [],
|
||||
sockets: [],
|
||||
timers: [],
|
||||
paths: [],
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter current tab's units
|
||||
const filteredUnits = useMemo(() => {
|
||||
let filtered = units[activeTab] || []
|
||||
|
||||
// Search filter (name or description)
|
||||
if (searchText) {
|
||||
filtered = filtered.filter((unit) => {
|
||||
const searchLower = searchText.toLowerCase()
|
||||
return (
|
||||
unit.name.toLowerCase().includes(searchLower) ||
|
||||
unit.description.toLowerCase().includes(searchLower)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Active status filter
|
||||
if (activeStatus === "active") {
|
||||
filtered = filtered.filter((unit) => unit.active === "active")
|
||||
} else if (activeStatus === "inactive") {
|
||||
filtered = filtered.filter((unit) => unit.active === "inactive")
|
||||
}
|
||||
|
||||
// File status filter
|
||||
if (fileStatus !== "all") {
|
||||
filtered = filtered.filter((unit) => {
|
||||
const sub = unit.sub.toLowerCase()
|
||||
if (fileStatus === "enabled") {
|
||||
return sub === "enabled"
|
||||
} else if (fileStatus === "disabled") {
|
||||
return sub === "disabled"
|
||||
} else if (fileStatus === "static") {
|
||||
return sub === "static"
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [units, activeTab, searchText, activeStatus, fileStatus])
|
||||
|
||||
const tabConfig: Record<
|
||||
UnitType,
|
||||
{ label: string; icon: React.ReactNode; count: number }
|
||||
> = {
|
||||
services: {
|
||||
label: "Dienste",
|
||||
icon: <Zap className="w-4 h-4" />,
|
||||
count: units.services.length,
|
||||
},
|
||||
targets: {
|
||||
label: "Ziele",
|
||||
icon: <Target className="w-4 h-4" />,
|
||||
count: units.targets.length,
|
||||
},
|
||||
sockets: {
|
||||
label: "Sockets",
|
||||
icon: <Wifi className="w-4 h-4" />,
|
||||
count: units.sockets.length,
|
||||
},
|
||||
timers: {
|
||||
label: "Timer",
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
count: units.timers.length,
|
||||
},
|
||||
paths: {
|
||||
label: "Pfade",
|
||||
icon: <FolderOpen className="w-4 h-4" />,
|
||||
count: units.paths.length,
|
||||
},
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
if (status === "active") {
|
||||
return <Badge className="bg-green-600 hover:bg-green-700">Aktiv</Badge>
|
||||
}
|
||||
return <Badge variant="secondary">Inaktiv</Badge>
|
||||
}
|
||||
|
||||
const getSubStatusBadge = (sub: string) => {
|
||||
const subLower = sub.toLowerCase()
|
||||
if (subLower === "running") {
|
||||
return <Badge className="bg-blue-600 hover:bg-blue-700">Läuft</Badge>
|
||||
} else if (subLower === "enabled") {
|
||||
return <Badge className="bg-green-500 hover:bg-green-600">Aktiviert</Badge>
|
||||
} else if (subLower === "disabled") {
|
||||
return <Badge variant="secondary">Deaktiviert</Badge>
|
||||
} else if (subLower === "static") {
|
||||
return <Badge variant="outline">Statisch</Badge>
|
||||
}
|
||||
return <Badge variant="outline">{sub}</Badge>
|
||||
}
|
||||
|
||||
const hasActiveFilters = searchText || activeStatus !== "all" || fileStatus !== "all"
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Systemd Einheiten</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Dienste, Ziele, Sockets, Timer und Pfade
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={loadUnits} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{(Object.keys(tabConfig) as UnitType[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => {
|
||||
setActiveTab(tab)
|
||||
setSearchText("")
|
||||
setActiveStatus("all")
|
||||
setFileStatus("all")
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors ${
|
||||
activeTab === tab
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "border border-border hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{tabConfig[tab].icon}
|
||||
{tabConfig[tab].label}
|
||||
<span className="text-xs ml-1 opacity-75">
|
||||
({tabConfig[tab].count})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter Section */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Search Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Nach Name oder Beschreibung
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. ssh, apache..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Aktiver Status
|
||||
</label>
|
||||
<select
|
||||
value={activeStatus}
|
||||
onChange={(e) => setActiveStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* File Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Dateistatus
|
||||
</label>
|
||||
<select
|
||||
value={fileStatus}
|
||||
onChange={(e) => setFileStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="enabled">Aktiviert</option>
|
||||
<option value="disabled">Deaktiviert</option>
|
||||
<option value="static">Statisch</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
{hasActiveFilters && (
|
||||
<div className="mt-4 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-muted-foreground">Filter:</span>
|
||||
{searchText && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-purple-500/10 text-purple-600 text-xs">
|
||||
"{searchText}"
|
||||
<button
|
||||
onClick={() => setSearchText("")}
|
||||
className="hover:text-purple-600/70"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{activeStatus !== "all" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-green-500/10 text-green-600 text-xs">
|
||||
{activeStatus === "active" ? "Aktiv" : "Inaktiv"}
|
||||
<button
|
||||
onClick={() => setActiveStatus("all")}
|
||||
className="hover:text-green-600/70"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{fileStatus !== "all" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-500/10 text-blue-600 text-xs">
|
||||
{fileStatus === "enabled"
|
||||
? "Aktiviert"
|
||||
: fileStatus === "disabled"
|
||||
? "Deaktiviert"
|
||||
: "Statisch"}
|
||||
<button
|
||||
onClick={() => setFileStatus("all")}
|
||||
className="hover:text-blue-600/70"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Units Table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium">
|
||||
Aktiver Status
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 font-medium">
|
||||
Dateistatus
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 font-medium">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUnits.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="py-8 px-4 text-center text-muted-foreground"
|
||||
>
|
||||
{loading ? "Lädt..." : "Keine Einheiten gefunden"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredUnits.map((unit, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className="border-b border-border/50 hover:bg-muted/30"
|
||||
>
|
||||
<td className="py-3 px-4 font-mono text-xs">
|
||||
{unit.name}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{getStatusBadge(unit.active)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{getSubStatusBadge(unit.sub)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground truncate">
|
||||
{unit.description || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-xs text-muted-foreground border-t">
|
||||
Zeige {filteredUnits.length} von {units[activeTab]?.length || 0}{" "}
|
||||
{tabConfig[activeTab].label.toLowerCase()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { api } from "@/lib/api"
|
||||
import { Header } from "@/components/Header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { RefreshCw, Plus, Trash2, AlertCircle } from "lucide-react"
|
||||
import CreateSambaDialog from "@/components/shares/CreateSambaDialog"
|
||||
import CreateNfsDialog from "@/components/shares/CreateNfsDialog"
|
||||
import DeleteConfirmDialog from "@/components/shares/DeleteConfirmDialog"
|
||||
|
||||
export default function SharesPage() {
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState<"samba" | "nfs">("samba")
|
||||
const [sambaShares, setSambaShares] = useState<any[]>([])
|
||||
const [nfsShares, setNfsShares] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showSambaDialog, setShowSambaDialog] = useState(false)
|
||||
const [showNfsDialog, setShowNfsDialog] = useState(false)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ type: "samba" | "nfs"; name: string } | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (!token) {
|
||||
router.push("/login")
|
||||
return
|
||||
}
|
||||
loadShares()
|
||||
}, [router])
|
||||
|
||||
const loadShares = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [samba, nfs] = await Promise.all([
|
||||
api.getSambaShares().catch(() => []),
|
||||
api.getNfsShares().catch(() => []),
|
||||
])
|
||||
|
||||
setSambaShares(samba)
|
||||
setNfsShares(nfs)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load shares")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSamba = async (name: string) => {
|
||||
try {
|
||||
setDeleting(true)
|
||||
await api.deleteSambaShare(name)
|
||||
setSambaShares(sambaShares.filter((s) => s.name !== name))
|
||||
setDeleteConfirm(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete share")
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteNfs = async (path: string) => {
|
||||
try {
|
||||
setDeleting(true)
|
||||
await api.deleteNfsShare(path)
|
||||
setNfsShares(nfsShares.filter((s) => s.path !== path))
|
||||
setDeleteConfirm(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete share")
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSambaCreated = (newShare: any) => {
|
||||
setSambaShares([...sambaShares, newShare])
|
||||
setShowSambaDialog(false)
|
||||
}
|
||||
|
||||
const handleNfsCreated = (newShare: any) => {
|
||||
setNfsShares([...nfsShares, newShare])
|
||||
setShowNfsDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">File Sharing</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage Samba (SMB) and NFS network shares</p>
|
||||
</div>
|
||||
<Button onClick={loadShares} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="mb-6 border-red-200 bg-red-50">
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-red-900">Error</p>
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border mb-6">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab("samba")}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
activeTab === "samba"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
SMB/Samba
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("nfs")}
|
||||
className={`px-4 py-2 font-medium transition-colors ${
|
||||
activeTab === "nfs"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
NFS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SAMBA TAB */}
|
||||
{activeTab === "samba" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Samba Shares</CardTitle>
|
||||
<Button size="sm" onClick={() => setShowSambaDialog(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Share
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sambaShares.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-12">
|
||||
No Samba shares configured. Create one to get started.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Path</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Users</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Perms</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Comment</th>
|
||||
<th className="text-right py-3 px-4 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sambaShares.map((share) => (
|
||||
<tr key={share.name} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 font-mono text-xs">{share.name}</td>
|
||||
<td className="py-3 px-4 font-mono text-xs">{share.path}</td>
|
||||
<td className="py-3 px-4 text-xs">{share.valid_users || "—"}</td>
|
||||
<td className="py-3 px-4 text-xs">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
|
||||
{share.read_only ? "RO" : "RW"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs">{share.comment || "—"}</td>
|
||||
<td className="py-3 px-4 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ type: "samba", name: share.name })}
|
||||
className="text-red-600 hover:text-red-700 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 inline" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* NFS TAB */}
|
||||
{activeTab === "nfs" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>NFS Shares</CardTitle>
|
||||
<Button size="sm" onClick={() => setShowNfsDialog(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Share
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{nfsShares.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-12">
|
||||
No NFS shares configured. Create one to get started.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">Path</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Clients</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Options</th>
|
||||
<th className="text-right py-3 px-4 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nfsShares.map((share) => (
|
||||
<tr key={share.path} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 font-mono text-xs">{share.path}</td>
|
||||
<td className="py-3 px-4 text-xs">{share.clients}</td>
|
||||
<td className="py-3 px-4 text-xs font-mono text-xs">{share.options || "—"}</td>
|
||||
<td className="py-3 px-4 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ type: "nfs", name: share.path })}
|
||||
className="text-red-600 hover:text-red-700 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 inline" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Dialogs */}
|
||||
<CreateSambaDialog open={showSambaDialog} onOpenChange={setShowSambaDialog} onCreated={handleSambaCreated} />
|
||||
<CreateNfsDialog open={showNfsDialog} onOpenChange={setShowNfsDialog} onCreated={handleNfsCreated} />
|
||||
<DeleteConfirmDialog
|
||||
open={!!deleteConfirm}
|
||||
onOpenChange={(open) => !open && setDeleteConfirm(null)}
|
||||
type={deleteConfirm?.type === "samba" ? "Samba Share" : "NFS Share"}
|
||||
name={deleteConfirm?.name || ""}
|
||||
onConfirm={() => {
|
||||
if (deleteConfirm?.type === "samba") {
|
||||
handleDeleteSamba(deleteConfirm.name)
|
||||
} else if (deleteConfirm?.type === "nfs") {
|
||||
handleDeleteNfs(deleteConfirm.name)
|
||||
}
|
||||
}}
|
||||
loading={deleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { api, Snapshot, Dataset } from "@/lib/api"
|
||||
import { Header } from "@/components/Header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog } from "@/components/ui/dialog"
|
||||
import { RefreshCw, AlertCircle, Trash2, Plus, RotateCcw, ChevronRight, ChevronDown } from "lucide-react"
|
||||
import { formatBytes } from "@/lib/utils"
|
||||
|
||||
function formatUnix(ts: number) {
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
export default function SnapshotsPage() {
|
||||
const router = useRouter()
|
||||
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
const [filterDataset, setFilterDataset] = useState("")
|
||||
|
||||
// Create dialog
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createDataset, setCreateDataset] = useState("")
|
||||
const [createName, setCreateName] = useState("")
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// Rollback dialog
|
||||
const [rollbackTarget, setRollbackTarget] = useState<string | null>(null)
|
||||
const [rollingBack, setRollingBack] = useState(false)
|
||||
const [expandedDatasets, setExpandedDatasets] = useState<Set<string>>(new Set())
|
||||
|
||||
const fetchSnapshots = useCallback(async (dataset?: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await api.getSnapshots(dataset || undefined)
|
||||
setSnapshots(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch snapshots")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (!token) { router.push("/login"); return }
|
||||
|
||||
fetchSnapshots()
|
||||
api.getDatasets().then(setDatasets).catch(() => {})
|
||||
const iv = setInterval(() => fetchSnapshots(filterDataset || undefined), 60000)
|
||||
return () => clearInterval(iv)
|
||||
}, [router, fetchSnapshots, filterDataset])
|
||||
|
||||
const handleFilterChange = (ds: string) => {
|
||||
setFilterDataset(ds)
|
||||
fetchSnapshots(ds || undefined)
|
||||
}
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
if (!confirm(`Delete snapshot "${name}"?\nThis cannot be undone.`)) return
|
||||
try {
|
||||
setDeleting(name)
|
||||
await api.deleteSnapshot(name)
|
||||
setSnapshots((prev) => prev.filter((s) => s.name !== name))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete snapshot")
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!createDataset) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await api.createSnapshot(createDataset, createName || undefined)
|
||||
setCreateOpen(false)
|
||||
setCreateDataset("")
|
||||
setCreateName("")
|
||||
fetchSnapshots(filterDataset || undefined)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create snapshot")
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRollback = async () => {
|
||||
if (!rollbackTarget) return
|
||||
setRollingBack(true)
|
||||
try {
|
||||
await api.rollbackSnapshot(rollbackTarget)
|
||||
setRollbackTarget(null)
|
||||
fetchSnapshots(filterDataset || undefined)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Rollback failed")
|
||||
} finally {
|
||||
setRollingBack(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Unique dataset names for filter dropdown
|
||||
const datasetNames = Array.from(
|
||||
new Set(snapshots.map((s) => s.name.split("@")[0]).filter(Boolean))
|
||||
).sort()
|
||||
|
||||
const getDatasetDepth = (name: string): number => {
|
||||
return name.split("/").length - 1
|
||||
}
|
||||
|
||||
const getSnapshotsByDataset = (dsName: string): Snapshot[] => {
|
||||
return snapshots.filter((s) => s.name.split("@")[0] === dsName)
|
||||
}
|
||||
|
||||
const getTopLevelDatasets = (): string[] => {
|
||||
const topLevel = new Set<string>()
|
||||
snapshots.forEach((snap) => {
|
||||
const dsName = snap.name.split("@")[0]
|
||||
const topDsName = dsName.split("/")[0]
|
||||
topLevel.add(topDsName)
|
||||
})
|
||||
return Array.from(topLevel).sort()
|
||||
}
|
||||
|
||||
const getAllDatasetsByPrefix = (prefix?: string): string[] => {
|
||||
const allDs = new Set<string>()
|
||||
snapshots.forEach((snap) => {
|
||||
const dsName = snap.name.split("@")[0]
|
||||
if (!prefix) {
|
||||
if (dsName.split("/").length === 1) allDs.add(dsName)
|
||||
} else {
|
||||
const dsPrefix = prefix + "/"
|
||||
if (dsName.startsWith(dsPrefix) && dsName !== prefix) {
|
||||
const remaining = dsName.slice(dsPrefix.length)
|
||||
if (remaining.split("/").length === 1) {
|
||||
allDs.add(dsName)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return Array.from(allDs).sort()
|
||||
}
|
||||
|
||||
const toggleExpand = (name: string) => {
|
||||
const newExpanded = new Set(expandedDatasets)
|
||||
if (newExpanded.has(name)) {
|
||||
newExpanded.delete(name)
|
||||
} else {
|
||||
newExpanded.add(name)
|
||||
}
|
||||
setExpandedDatasets(newExpanded)
|
||||
}
|
||||
|
||||
const renderSnapshotTree = (parentDs?: string): React.ReactNode[] => {
|
||||
const items: React.ReactNode[] = []
|
||||
const datasets = parentDs ? getAllDatasetsByPrefix(parentDs) : getTopLevelDatasets()
|
||||
|
||||
datasets.forEach((dsName) => {
|
||||
const childDatasets = getAllDatasetsByPrefix(dsName)
|
||||
const isExpanded = expandedDatasets.has(dsName)
|
||||
const depth = getDatasetDepth(dsName)
|
||||
const snapshotsForDs = getSnapshotsByDataset(dsName)
|
||||
|
||||
snapshotsForDs.forEach((snap, idx) => {
|
||||
const [, tag] = snap.name.split("@")
|
||||
items.push(
|
||||
<tr key={snap.name} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 text-muted-foreground font-mono text-xs" style={{ paddingLeft: `${depth * 24 + 16}px` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{idx === 0 && childDatasets.length > 0 && (
|
||||
<button
|
||||
onClick={() => toggleExpand(dsName)}
|
||||
className="p-0 hover:bg-muted rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{(idx > 0 || childDatasets.length === 0) && <div className="w-4" />}
|
||||
<span>{idx === 0 ? dsName.split("/").pop() : ""}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono text-xs font-medium">{tag}</td>
|
||||
<td className="py-3 px-4 text-muted-foreground">{formatUnix(snap.creation)}</td>
|
||||
<td className="py-3 px-4">{formatBytes(snap.used)}</td>
|
||||
<td className="py-3 px-4">{formatBytes(snap.referenced)}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Rollback to this snapshot"
|
||||
onClick={() => setRollbackTarget(snap.name)}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Delete snapshot"
|
||||
onClick={() => handleDelete(snap.name)}
|
||||
disabled={deleting === snap.name}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
|
||||
if (isExpanded && childDatasets.length > 0) {
|
||||
items.push(...renderSnapshotTree(dsName))
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Snapshots</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage ZFS snapshots</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => fetchSnapshots(filterDataset || undefined)} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Snapshot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<label className="text-sm text-muted-foreground">Dataset:</label>
|
||||
<select
|
||||
value={filterDataset}
|
||||
onChange={(e) => handleFilterChange(e.target.value)}
|
||||
className="text-sm bg-background border border-border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">All datasets</option>
|
||||
{datasetNames.map((ds) => (
|
||||
<option key={ds} value={ds}>{ds}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-muted-foreground">{snapshots.length} snapshots</span>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-6 flex items-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && snapshots.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<RefreshCw className="w-8 h-8 text-muted-foreground animate-spin mx-auto" />
|
||||
<p className="mt-4 text-muted-foreground">Loading snapshots…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{snapshots.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Snapshots</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Dataset</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Snapshot</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Created</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Used</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Referenced</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderSnapshotTree()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && snapshots.length === 0 && !error && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
No snapshots found.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Create Snapshot Dialog */}
|
||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title="Create Snapshot">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Dataset *</label>
|
||||
<select
|
||||
value={createDataset}
|
||||
onChange={(e) => setCreateDataset(e.target.value)}
|
||||
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">Select dataset…</option>
|
||||
{datasets.filter((d) => d.type === "filesystem").map((d) => (
|
||||
<option key={d.name} value={d.name}>{d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Name <span className="text-muted-foreground font-normal">(optional, auto-generated if empty)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder="e.g. before-upgrade"
|
||||
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreate} disabled={!createDataset || creating}>
|
||||
{creating ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Rollback Dialog */}
|
||||
<Dialog
|
||||
open={!!rollbackTarget}
|
||||
onClose={() => setRollbackTarget(null)}
|
||||
title="Rollback to Snapshot"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<strong>Warning:</strong> Rollback will permanently destroy all data written after this snapshot.
|
||||
This cannot be undone.
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
Roll back to: <span className="font-mono font-medium">{rollbackTarget}</span>?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setRollbackTarget(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleRollback}
|
||||
disabled={rollingBack}
|
||||
>
|
||||
{rollingBack ? "Rolling back…" : "Rollback"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user