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:
Claude Code
2026-04-22 00:26:23 +02:00
committed by Patrick
commit 92bed208e0
108 changed files with 29925 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# API Configuration
# Point to the FastAPI backend
NEXT_PUBLIC_API_URL=http://localhost:8000
# For production, use:
# NEXT_PUBLIC_API_URL=https://zfs-manager.example.com:9090
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+37
View File
@@ -0,0 +1,37 @@
# Dependencies
node_modules
/.pnp
.pnp.js
# Testing
/coverage
# Next.js
/.next/
/out/
# Production
/build
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.vscode
.idea
*.swp
*.swo
*~
+1718
View File
File diff suppressed because it is too large Load Diff
+206
View File
@@ -0,0 +1,206 @@
# ZMB Webui Frontend
Modern Next.js 15 web UI for ZFS storage management. Built with TypeScript, Tailwind CSS, and ISR optimization for performance on resource-constrained systems.
## Features
- **Dashboard**: Real-time pool status, capacity visualization, health monitoring
- **Snapshots**: Create, manage, and delete ZFS snapshots
- **File Manager**: Browse and manage files (coming soon)
- **Authentication**: JWT-based login with password hashing
- **Responsive Design**: Works on mobile, tablet, and desktop
- **Performance**: ISR (Incremental Static Regeneration) for fast page loads
- **Dark Mode Ready**: Full dark mode support with Tailwind CSS
## Quick Start
### Prerequisites
- Node.js 18+ (for development)
- npm or yarn
- FastAPI backend running on http://localhost:8000
### Local Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Open http://localhost:3000 in your browser
```
### Production Build
```bash
# Build for production
npm run build
# Start production server
npm start
# Or export to static HTML
npm run export
```
## Configuration
Copy `.env.example` to `.env.local` and update the API URL:
```bash
cp .env.example .env.local
```
Edit `.env.local`:
```
NEXT_PUBLIC_API_URL=http://your-backend-host:8000
```
## Architecture
### Pages
- `/` - Dashboard (pool overview)
- `/login` - Authentication
- `/snapshots` - Snapshot management
- `/files` - File browser (coming soon)
### Components
- `PoolCard` - Individual pool display with health/capacity
- `Header` - Navigation and user menu
- UI components (Card, Button, Badge, Progress)
### API Client
`lib/api.ts` - TypeScript client for FastAPI backend with:
- Authentication (login/logout)
- Pool management
- Snapshot operations
- File browsing
- System information
## Performance Optimizations
### For 4GB RAM Systems
- **ISR Strategy**:
- Dashboard revalidates every 30s
- Snapshots revalidate every 60s
- Static pages cached long-term
- **Bundle Optimization**:
- Tree-shaking for unused imports
- Dynamic imports for heavy components
- Compression enabled by default
- **Caching**:
- Browser caching for assets
- In-memory API response caching
- Service worker support ready
## Development
### Add a New Page
```bash
# Create app/new-feature/page.tsx
mkdir app/new-feature
touch app/new-feature/page.tsx
```
### Add a New Component
```bash
# Create components/MyComponent.tsx
touch components/MyComponent.tsx
```
### Use the API Client
```typescript
import { api } from "@/lib/api"
// Login
await api.login("admin", "password")
// Get pools
const pools = await api.getPools()
// Get snapshots
const snapshots = await api.getSnapshots()
```
## Deployment
### On Raspberry Pi / ARM64
```bash
# Build on faster x86 machine
npm run build
# Copy .next directory to Pi
scp -r .next pi@10.66.120.3:/opt/zmb-webui/frontend/
# Or build directly on Pi (slower)
npm run build
npm start
```
### With nginx
```nginx
server {
listen 443 ssl http2;
server_name zmb-webui.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
### Systemd Service
See `../deploy/zmb-webui-frontend.service` for service configuration.
## Troubleshooting
### Port 3000 already in use
```bash
# Use different port
npm run dev -- -p 3001
```
### API connection refused
Check `.env.local` points to correct backend URL:
```bash
NEXT_PUBLIC_API_URL=http://localhost:8000
```
### Build hangs on ARM64
The Node.js build process can be slow on Raspberry Pi. Either:
1. Build on faster x86 machine and copy artifacts
2. Increase available RAM/swap
3. Use pre-built Docker image
## Contributing
See main project README for contribution guidelines.
## License
Same as parent project
+747
View File
@@ -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>
)
}
+256
View File
@@ -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>
)
}
+76
View File
@@ -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;
}
}
+877
View File
@@ -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>
)
}
+21
View File
@@ -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>
)
}
+117
View File
@@ -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>
)
}
+340
View File
@@ -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">
&quot;{searchText}&quot;
<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
+597
View File
@@ -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>
)
}
+364
View File
@@ -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">
&quot;{searchText}&quot;
<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>
)
}
+278
View File
@@ -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>
)
}
+386
View File
@@ -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>
)
}
+267
View File
@@ -0,0 +1,267 @@
"use client"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { HardDrive, Menu, LogOut } from "lucide-react"
import { useState, useEffect } from "react"
import { api } from "@/lib/api"
export function Header() {
const pathname = usePathname()
const router = useRouter()
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [zfsAvailable, setZfsAvailable] = useState(false)
useEffect(() => {
const checkZfsAvailability = async () => {
const status = await api.getSystemStatus()
setZfsAvailable(status.zfs_available)
}
checkZfsAvailability()
}, [])
const handleLogout = async () => {
await api.logout()
router.push("/login")
}
const isActive = (path: string) => pathname === path
return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 font-bold text-lg">
<HardDrive className="w-6 h-6" />
<span>ZMB Webui</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-1">
<Link
href="/"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Dashboard
</Link>
{zfsAvailable && (
<>
<Link
href="/snapshots"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/snapshots")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Snapshots
</Link>
<Link
href="/datasets"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/datasets")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Datasets
</Link>
</>
)}
<Link
href="/navigator"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/navigator")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Navigator
</Link>
<Link
href="/shares"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/shares")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Shares
</Link>
<Link
href="/file-sharing"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/file-sharing")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
File Sharing
</Link>
<Link
href="/identities"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/identities")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Identities
</Link>
<Link
href="/logs"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/logs")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Logs
</Link>
<Link
href="/services"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/services")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Services
</Link>
</nav>
{/* Logout Button */}
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="w-4 h-4 mr-2" />
<span className="hidden sm:inline">Logout</span>
</Button>
{/* Mobile Menu Button */}
<button
className="md:hidden p-2"
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
<Menu className="w-6 h-6" />
</button>
</div>
</div>
{/* Mobile Navigation */}
{isMenuOpen && (
<nav className="md:hidden pb-4 space-y-1">
<Link
href="/"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Dashboard
</Link>
{zfsAvailable && (
<>
<Link
href="/snapshots"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/snapshots")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Snapshots
</Link>
<Link
href="/datasets"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/datasets")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Datasets
</Link>
</>
)}
<Link
href="/files"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/files")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Files
</Link>
<Link
href="/shares"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/shares")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Shares
</Link>
<Link
href="/file-sharing"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/file-sharing")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
File Sharing
</Link>
<Link
href="/identities"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/identities")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Identities
</Link>
<Link
href="/logs"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/logs")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Logs
</Link>
<Link
href="/services"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/services")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Services
</Link>
</nav>
)}
</div>
</header>
)
}
+69
View File
@@ -0,0 +1,69 @@
"use client"
import { Pool } from "@/lib/api"
import { formatBytes } from "@/lib/utils"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
interface PoolCardProps {
pool: Pool
onClick?: () => void
}
export function PoolCard({ pool, onClick }: PoolCardProps) {
const usedBytes = pool.alloc
const freeBytes = pool.free
const totalBytes = pool.size
const capacityPercent = parseInt(pool.capacity)
let badgeVariant: "success" | "warning" | "destructive" = "success"
if (pool.health === "DEGRADED") badgeVariant = "warning"
else if (pool.health !== "ONLINE") badgeVariant = "destructive"
return (
<Card
onClick={onClick}
className={`cursor-pointer hover:shadow-lg transition-shadow ${onClick ? "cursor-pointer" : ""}`}
>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-xl">{pool.name}</CardTitle>
<Badge variant={badgeVariant}>{pool.health}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Capacity Bar */}
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-muted-foreground">Capacity</span>
<span className="font-medium">{pool.capacity}</span>
</div>
<Progress value={capacityPercent} max={100} />
</div>
{/* Size Information */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<div className="text-muted-foreground text-xs">Total</div>
<div className="font-medium">{formatBytes(totalBytes)}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Used</div>
<div className="font-medium">{formatBytes(usedBytes)}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Free</div>
<div className="font-medium">{formatBytes(freeBytes)}</div>
</div>
</div>
{/* Fragmentation */}
<div className="flex justify-between items-center text-sm pt-2 border-t border-border">
<span className="text-muted-foreground">Fragmentation</span>
<span className="font-medium">{pool.fragmentation}</span>
</div>
</CardContent>
</Card>
)
}
+97
View File
@@ -0,0 +1,97 @@
"use client"
import { Vdev } from "@/lib/api"
function stateColor(state: string) {
switch (state?.toUpperCase()) {
case "ONLINE": return "text-green-500"
case "DEGRADED": return "text-yellow-500"
case "FAULTED":
case "OFFLINE":
case "UNAVAIL": return "text-red-500"
default: return "text-muted-foreground"
}
}
function stateIcon(state: string) {
switch (state?.toUpperCase()) {
case "ONLINE": return "●"
case "DEGRADED": return "◑"
default: return "○"
}
}
interface VdevRowProps {
vdev: Vdev
depth?: number
}
function VdevRow({ vdev, depth = 0 }: VdevRowProps) {
const hasErrors =
vdev.read !== 0 || vdev.write !== 0 || vdev.cksum !== 0
return (
<>
<tr className="border-b border-border/50 last:border-0 hover:bg-muted/30">
<td className="py-2 pr-4">
<span style={{ paddingLeft: `${depth * 20}px` }} className="flex items-center gap-2">
<span className={`text-sm ${stateColor(vdev.state)}`}>{stateIcon(vdev.state)}</span>
<span className={`font-mono text-sm ${depth === 0 ? "font-semibold" : ""}`}>
{vdev.name}
</span>
</span>
</td>
<td className={`py-2 px-3 text-sm font-medium ${stateColor(vdev.state)}`}>
{vdev.state}
</td>
<td className={`py-2 px-3 text-sm font-mono text-center ${hasErrors ? "text-red-500 font-bold" : "text-muted-foreground"}`}>
{vdev.read}
</td>
<td className={`py-2 px-3 text-sm font-mono text-center ${hasErrors ? "text-red-500 font-bold" : "text-muted-foreground"}`}>
{vdev.write}
</td>
<td className={`py-2 px-3 text-sm font-mono text-center ${hasErrors ? "text-red-500 font-bold" : "text-muted-foreground"}`}>
{vdev.cksum}
</td>
</tr>
{vdev.children?.map((child) => (
<VdevRow key={child.name} vdev={child} depth={depth + 1} />
))}
</>
)
}
interface VdevTreeProps {
vdevs: Vdev[]
}
export function VdevTree({ vdevs }: VdevTreeProps) {
if (!vdevs || vdevs.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4">
No VDEV information available.
</p>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground uppercase tracking-wide">
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 px-3">State</th>
<th className="pb-2 px-3 text-center">Read</th>
<th className="pb-2 px-3 text-center">Write</th>
<th className="pb-2 px-3 text-center">CkSum</th>
</tr>
</thead>
<tbody>
{vdevs.map((vdev) => (
<VdevRow key={vdev.name} vdev={vdev} depth={0} />
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,260 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import { ChevronRight, ChevronDown, Folder, Loader2 } from "lucide-react"
interface DirNode {
name: string
path: string
has_children: boolean
}
interface DirectoryTreeProps {
currentPath: string
onNavigate: (path: string) => void
}
interface TreeNodeProps {
node: DirNode
depth: number
isActive: boolean
isExpanded: boolean
isLoading: boolean
subdirs: DirNode[]
basePath: string
onExpand: (path: string) => void
onNavigate: (path: string) => void
}
const TreeNode = ({
node,
depth,
isActive,
isExpanded,
isLoading,
subdirs,
basePath,
onExpand,
onNavigate,
}: TreeNodeProps) => {
return (
<div>
<div
className={`flex items-center gap-1 px-2 py-1 rounded cursor-pointer transition-colors ${
isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
>
{node.has_children ? (
<button
onClick={() => onExpand(node.path)}
className="p-0 w-4 h-4 flex items-center justify-center"
>
{isLoading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
) : (
<div className="w-4" />
)}
<Folder className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
<button
onClick={() => {
const fullPath = basePath === "/" ? "/" + node.path : basePath + "/" + node.path
onNavigate(fullPath)
}}
className="text-sm truncate text-left"
>
{node.name}
</button>
</div>
{isExpanded && subdirs.length > 0 && (
<div>
{subdirs.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
isActive={isActive}
isExpanded={false}
isLoading={false}
subdirs={[]}
basePath={basePath}
onExpand={onExpand}
onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
)
}
const BookmarkButton = ({
label,
path,
onNavigate,
}: {
label: string
path: string
onNavigate: (path: string) => void
}) => (
<button
onClick={() => onNavigate(path)}
className="w-full text-left px-2 py-1.5 rounded text-xs hover:bg-muted/50 transition-colors"
>
{label}
</button>
)
export function DirectoryTree({
currentPath,
onNavigate,
}: DirectoryTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [childrenMap, setChildrenMap] = useState<Map<string, DirNode[]>>(
new Map()
)
const [loading, setLoading] = useState<Set<string>>(new Set())
const getAuthHeader = () => {
const token = localStorage.getItem("access_token")
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
}
}
const getApiUrl = (path: string) => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || ""
return baseUrl + path
}
const fetchChildren = useCallback(
async (path: string) => {
// If already loaded, just toggle expand
if (childrenMap.has(path)) {
setExpanded((prev) => {
const newSet = new Set(prev)
if (newSet.has(path)) {
newSet.delete(path)
} else {
newSet.add(path)
}
return newSet
})
return
}
// Fetch children
setLoading((prev) => new Set(prev).add(path))
try {
const qs = `path=${encodeURIComponent(path)}&admin=true`
const res = await fetch(getApiUrl(`/api/navigator/dirs?${qs}`), {
headers: getAuthHeader(),
})
const data = await res.json()
setChildrenMap((prev) => new Map(prev).set(path, data.dirs || []))
setExpanded((prev) => new Set(prev).add(path))
} catch (err) {
console.error("Failed to fetch subdirectories:", err)
} finally {
setLoading((prev) => {
const newSet = new Set(prev)
newSet.delete(path)
return newSet
})
}
},
[childrenMap]
)
// Auto-expand ancestors when currentPath changes
useEffect(() => {
if (!currentPath || currentPath === "/") return
const parts = currentPath.replace(/^\//, "").split("/").filter(Boolean)
for (let i = 0; i < parts.length; i++) {
const ancestorPath = parts.slice(0, i).join("/")
if (!childrenMap.has(ancestorPath)) {
fetchChildren(ancestorPath)
} else {
setExpanded((prev) => new Set(prev).add(ancestorPath))
}
}
}, [currentPath, childrenMap, fetchChildren])
// Load root on mount
useEffect(() => {
fetchChildren("")
}, [fetchChildren])
const basePath = "/"
const rootPath = ""
const rootChildren = childrenMap.get(rootPath) || []
return (
<div className="space-y-3">
{/* Bookmarks Section */}
<div className="border-b border-border pb-2">
<p className="text-xs font-semibold text-muted-foreground px-2 mb-1">
Favoriten
</p>
<div className="space-y-0.5">
<BookmarkButton label="Wurzel" path="/" onNavigate={onNavigate} />
<BookmarkButton label="Home" path="/home" onNavigate={onNavigate} />
<BookmarkButton label="Root" path="/root" onNavigate={onNavigate} />
<BookmarkButton label="Tank" path="/tank" onNavigate={onNavigate} />
<BookmarkButton
label="Var/Log"
path="/var/log"
onNavigate={onNavigate}
/>
</div>
</div>
{/* Directory Tree */}
<div className="space-y-0.5">
<p className="text-xs font-semibold text-muted-foreground px-2">
Verzeichnisse
</p>
<div
className="space-y-0.5"
style={{ maxHeight: "calc(100vh - 14rem)", overflowY: "auto" }}
>
{rootChildren.length > 0 ? (
rootChildren.map((node) => {
const fullPath = basePath === "/" ? "/" + node.path : basePath + "/" + node.path
return (
<TreeNode
key={node.path}
node={node}
depth={0}
isActive={currentPath === fullPath}
isExpanded={expanded.has(node.path)}
isLoading={loading.has(node.path)}
subdirs={childrenMap.get(node.path) || []}
basePath={basePath}
onExpand={fetchChildren}
onNavigate={onNavigate}
/>
)
})
) : (
<p className="text-xs text-muted-foreground px-2 py-1">
{loading.has(rootPath) ? "Laden..." : "Keine Verzeichnisse"}
</p>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,166 @@
"use client"
import { useState } from "react"
import { api } from "@/lib/api"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AlertCircle } from "lucide-react"
interface CreateNfsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (share: any) => void
}
export default function CreateNfsDialog({
open,
onOpenChange,
onCreated,
}: CreateNfsDialogProps) {
const [path, setPath] = useState("")
const [clients, setClients] = useState("")
const [readonly, setReadonly] = useState(false)
const [sync, setSync] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (!path.trim()) {
setError("Path is required")
return
}
if (!clients.trim()) {
setError("Clients are required")
return
}
// Build options
const opts = []
opts.push(readonly ? "ro" : "rw")
opts.push(sync ? "sync" : "async")
opts.push("no_subtree_check")
const options = opts.join(",")
try {
setLoading(true)
await api.createNfsShare({ path, clients, options })
onCreated({
path,
clients,
options,
})
setPath("")
setClients("")
setReadonly(false)
setSync(true)
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create share")
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Create NFS Share</CardTitle>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 rounded p-3 flex gap-2">
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Path</label>
<input
type="text"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="e.g., /tank/share"
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
disabled={loading}
/>
<p className="text-xs text-muted-foreground mt-1">
Must be an existing filesystem path
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Clients</label>
<textarea
value={clients}
onChange={(e) => setClients(e.target.value)}
placeholder="e.g., 192.168.1.0/24 10.0.0.0/8"
className="w-full px-3 py-2 border border-border rounded bg-background text-sm min-h-[80px] font-mono text-xs"
disabled={loading}
/>
<p className="text-xs text-muted-foreground mt-1">
Space-separated CIDR ranges or IPs (e.g., 192.168.1.0/24, 10.0.0.5)
</p>
</div>
<div className="space-y-3 border-t border-border pt-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={readonly}
onChange={(e) => setReadonly(e.target.checked)}
disabled={loading}
className="rounded border-border"
/>
<span className="text-sm font-medium">Read-Only</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={sync}
onChange={(e) => setSync(e.target.checked)}
disabled={loading}
className="rounded border-border"
/>
<span className="text-sm font-medium">Sync Mode</span>
<span className="text-xs text-muted-foreground">(safer than async)</span>
</label>
</div>
<div className="bg-blue-50 border border-blue-200 rounded p-3">
<p className="text-xs text-blue-800">
<strong>Generated options:</strong>
<br />
{readonly ? "ro" : "rw"},{sync ? "sync" : "async"},no_subtree_check
</p>
</div>
<div className="flex gap-3 justify-end pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Share"}
</Button>
</div>
</CardContent>
</form>
</Card>
</div>
)
}
@@ -0,0 +1,147 @@
"use client"
import { useState } from "react"
import { api } from "@/lib/api"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AlertCircle } from "lucide-react"
interface CreateSambaDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (share: any) => void
}
export default function CreateSambaDialog({
open,
onOpenChange,
onCreated,
}: CreateSambaDialogProps) {
const [name, setName] = useState("")
const [path, setPath] = useState("")
const [comment, setComment] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (!name.trim()) {
setError("Share name is required")
return
}
if (!path.trim()) {
setError("Path is required")
return
}
try {
setLoading(true)
await api.createSambaShare({ name, path, comment: comment || undefined })
// Return the created share
onCreated({
name,
path,
comment: comment || null,
valid_users: null,
read_only: false,
})
// Reset form
setName("")
setPath("")
setComment("")
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create share")
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Create Samba Share</CardTitle>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 rounded p-3 flex gap-2">
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Share Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., share, media, backup"
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
disabled={loading}
/>
<p className="text-xs text-muted-foreground mt-1">
Alphanumeric, max 15 characters
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Path</label>
<input
type="text"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="e.g., /tank/share"
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
disabled={loading}
/>
<p className="text-xs text-muted-foreground mt-1">
Must be an existing filesystem path
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description (optional)</label>
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Share description"
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
disabled={loading}
/>
</div>
<div className="bg-blue-50 border border-blue-200 rounded p-3">
<p className="text-xs text-blue-800">
<strong>Default permissions:</strong> Read/Write, authenticated users only
</p>
</div>
<div className="flex gap-3 justify-end pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Share"}
</Button>
</div>
</CardContent>
</form>
</Card>
</div>
)
}
@@ -0,0 +1,66 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AlertCircle } from "lucide-react"
interface DeleteConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
type: string
name: string
onConfirm: () => void
loading?: boolean
}
export default function DeleteConfirmDialog({
open,
onOpenChange,
type,
name,
onConfirm,
loading = false,
}: DeleteConfirmDialogProps) {
if (!open) return null
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
Delete {type}?
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<p className="text-sm text-muted-foreground mb-2">
Are you sure you want to delete this {type.toLowerCase()}?
</p>
<div className="bg-muted p-3 rounded border border-border">
<p className="font-mono text-sm break-all">{name}</p>
</div>
<p className="text-xs text-red-600 mt-3">
This action cannot be undone.
</p>
</div>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={loading}
>
{loading ? "Deleting..." : "Delete"}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: "default" | "secondary" | "destructive" | "outline" | "success" | "warning"
}
const variantStyles = {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
success: "border-transparent bg-green-100 text-green-800",
warning: "border-transparent bg-yellow-100 text-yellow-800",
}
export function Badge({
className = "",
variant = "default",
...props
}: BadgeProps) {
const baseStyles =
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
const variantStyle = variantStyles[variant]
return (
<div className={`${baseStyles} ${variantStyle} ${className}`} {...props} />
)
}
+44
View File
@@ -0,0 +1,44 @@
import React from "react"
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost"
size?: "default" | "sm" | "lg"
}
const variantStyles = {
default:
"bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 active:bg-secondary/70",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
}
const sizeStyles = {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3 text-sm",
lg: "h-11 rounded-md px-8",
}
export function Button({
className = "",
variant = "default",
size = "default",
...props
}: ButtonProps) {
const baseStyles =
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
const variantStyle = variantStyles[variant]
const sizeStyle = sizeStyles[size]
return (
<button
className={`${baseStyles} ${variantStyle} ${sizeStyle} ${className}`}
{...props}
/>
)
}
+68
View File
@@ -0,0 +1,68 @@
export function Card({
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`rounded-lg border border-border bg-card text-card-foreground shadow-sm ${className}`}
{...props}
/>
)
}
export function CardHeader({
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`flex flex-col space-y-1.5 p-6 ${className}`}
{...props}
/>
)
}
export function CardTitle({
className = "",
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h2
className={`text-2xl font-semibold leading-none tracking-tight ${className}`}
{...props}
/>
)
}
export function CardDescription({
className = "",
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={`text-sm text-muted-foreground ${className}`}
{...props}
/>
)
}
export function CardContent({
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={`p-6 pt-0 ${className}`} {...props} />
)
}
export function CardFooter({
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`flex items-center p-6 pt-0 ${className}`}
{...props}
/>
)
}
+53
View File
@@ -0,0 +1,53 @@
"use client"
import { useEffect } from "react"
interface DialogProps {
open: boolean
onClose: () => void
title: string
children: React.ReactNode
}
export function Dialog({ open, onClose, title, children }: DialogProps) {
// Close on Escape key
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }
document.addEventListener("keydown", handler)
return () => document.removeEventListener("keydown", handler)
}, [open, onClose])
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60" />
{/* Panel */}
<div
className="relative z-10 w-full max-w-md mx-4 rounded-lg border border-border bg-background shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{title}</h2>
<button
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
{/* Body */}
<div className="px-6 py-4">{children}</div>
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value: number
max?: number
color?: "default" | "success" | "warning" | "danger"
}
const colorStyles = {
default: "bg-primary",
success: "bg-green-600",
warning: "bg-yellow-600",
danger: "bg-red-600",
}
export function Progress({
value,
max = 100,
color = "default",
className = "",
...props
}: ProgressProps) {
const percentage = Math.min((value / max) * 100, 100)
let colorClass = colorStyles[color]
// Auto-select color based on percentage
if (color === "default") {
if (percentage >= 90) {
colorClass = colorStyles.danger
} else if (percentage >= 75) {
colorClass = colorStyles.warning
} else {
colorClass = colorStyles.success
}
}
return (
<div
className={`relative w-full h-2 rounded-full bg-secondary overflow-hidden ${className}`}
{...props}
>
<div
className={`h-full ${colorClass} transition-all`}
style={{ width: `${percentage}%` }}
/>
</div>
)
}
+480
View File
@@ -0,0 +1,480 @@
import axios, { AxiosInstance } from "axios"
export interface Pool {
name: string
size: number
alloc: number
free: number
fragmentation: string
capacity: string
health: "ONLINE" | "DEGRADED" | "FAULTED" | "OFFLINE" | "UNAVAIL"
}
export interface Vdev {
name: string
state: string
read: number
write: number
cksum: number
children?: Vdev[]
}
export interface PoolStatus extends Pool {
state?: string
scan?: string
errors?: string
vdevs: Vdev[]
}
export interface Dataset {
name: string
type: "filesystem" | "volume" | "snapshot"
used: number
avail: number
refer: number
mountpoint?: string
compression?: string
quota?: number
reservation?: number
}
export interface DatasetProperties {
compression?: string
quota?: number
reservation?: number
}
export interface Snapshot {
name: string
dataset?: string
creation: number
used: number
referenced: number
}
export interface SambaShare {
name: string
path: string
comment?: string
read_only?: boolean
guest_ok?: boolean
valid_users?: string
}
export interface NfsShare {
path: string
clients: string
options?: string
}
export interface SystemInfo {
hostname: string
uptime: number
memory_total: number
memory_used: number
memory_available: number
cpu_count: number
cpu_model: string
os: string
}
export interface SystemStatus {
status: string
zfs_available: boolean
version: string
}
export interface SystemUser {
username: string
uid: number
gid: number
home: string
shell: string
gecos?: string
locked?: boolean
groups: string[]
}
export interface SystemGroup {
groupname: string
gid: number
members: string[]
}
export interface LoginEntry {
user: string
terminal: string
host: string
login_time: string
logout_time?: string
duration?: string
}
export class ZFSManagerAPI {
private client: AxiosInstance
private token: string | null = null
constructor(baseURL: string = process.env.NEXT_PUBLIC_API_URL || "") {
this.client = axios.create({
baseURL,
headers: {
"Content-Type": "application/json",
},
})
if (typeof window !== "undefined") {
this.token = localStorage.getItem("access_token")
if (this.token) {
this.setAuthHeader()
}
}
this.client.interceptors.response.use(
(response) => response,
(error) => {
// Don't redirect on login endpoint itself
const isLoginEndpoint = error.config?.url?.includes("/api/auth/login")
if (error.response?.status === 401 && !isLoginEndpoint) {
localStorage.removeItem("access_token")
this.token = null
if (typeof window !== "undefined") {
window.location.href = "/login"
}
}
return Promise.reject(error)
}
)
}
private setAuthHeader() {
if (this.token) {
this.client.defaults.headers.common["Authorization"] = `Bearer ${this.token}`
}
}
// Auth
async login(username: string, password: string): Promise<{ access_token: string; token_type: string }> {
// Clear any existing token before login
localStorage.removeItem("access_token")
this.token = null
this.client.defaults.headers.common["Authorization"] = ""
try {
const response = await this.client.post("/api/auth/login", { username, password })
this.token = response.data.access_token
this.setAuthHeader()
if (this.token) {
localStorage.setItem("access_token", this.token)
}
return response.data
} catch (error: any) {
// Clear any invalid token on login failure
localStorage.removeItem("access_token")
this.token = null
this.client.defaults.headers.common["Authorization"] = ""
// Provide better error message
const message = error?.response?.data?.detail || error?.message || "Login failed"
const err = new Error(message)
throw err
}
}
async logout() {
this.token = null
this.client.defaults.headers.common["Authorization"] = ""
localStorage.removeItem("access_token")
}
async verifyToken(): Promise<{ valid: boolean; username: string }> {
try {
const response = await this.client.post("/api/auth/verify")
return response.data
} catch {
return { valid: false, username: "" }
}
}
// System Status (no auth required)
async getSystemStatus(): Promise<SystemStatus> {
try {
const response = await this.client.get("/api/status")
return response.data
} catch {
return { status: "unknown", zfs_available: false, version: "" }
}
}
// Pools
async getPools(): Promise<Pool[]> {
const response = await this.client.get("/api/pools/")
return response.data
}
async getPoolStatus(name: string): Promise<PoolStatus> {
const response = await this.client.get(`/api/pools/${name}`)
return response.data
}
async startScrub(poolName: string): Promise<{ status: string }> {
const response = await this.client.post(`/api/pools/${poolName}/scrub`)
return response.data
}
// Datasets
async getDatasets(pool: string = "tank"): Promise<Dataset[]> {
const response = await this.client.get("/api/datasets/", { params: { pool } })
return response.data
}
async createDataset(name: string, properties?: Record<string, string>): Promise<{ status: string }> {
const response = await this.client.post("/api/datasets/", { name, properties })
return response.data
}
async updateDatasetProperties(name: string, props: DatasetProperties): Promise<{ status: string }> {
const response = await this.client.patch(`/api/datasets/${name}`, props)
return response.data
}
async deleteDataset(name: string, recursive = false): Promise<{ status: string }> {
const response = await this.client.delete(`/api/datasets/${name}`, { params: { recursive } })
return response.data
}
// Snapshots
async getSnapshots(dataset?: string, limit = 50): Promise<Snapshot[]> {
const response = await this.client.get("/api/snapshots/", {
params: { ...(dataset ? { dataset } : {}), limit },
})
return response.data
}
async createSnapshot(dataset: string, name?: string): Promise<{ status: string }> {
const response = await this.client.post("/api/snapshots/", { dataset, name })
return response.data
}
async deleteSnapshot(name: string): Promise<{ status: string }> {
const response = await this.client.delete(`/api/snapshots/${name}`)
return response.data
}
async rollbackSnapshot(snapshot: string): Promise<{ status: string }> {
const response = await this.client.post("/api/snapshots/rollback", { snapshot })
return response.data
}
// Shares — Samba
async getSambaShares(): Promise<SambaShare[]> {
const response = await this.client.get("/api/shares/samba")
return response.data.shares ?? response.data
}
async createSambaShare(share: SambaShare): Promise<{ status: string }> {
const response = await this.client.post("/api/shares/samba", share)
return response.data
}
async deleteSambaShare(name: string): Promise<{ status: string }> {
const response = await this.client.delete(`/api/shares/samba/${name}`)
return response.data
}
// Shares — NFS
async getNfsShares(): Promise<NfsShare[]> {
const response = await this.client.get("/api/shares/nfs")
return response.data.shares ?? response.data
}
async createNfsShare(share: NfsShare): Promise<{ status: string }> {
const response = await this.client.post("/api/shares/nfs", share)
return response.data
}
async deleteNfsShare(path: string): Promise<{ status: string }> {
const response = await this.client.delete("/api/shares/nfs", { params: { path } })
return response.data
}
// Shares Configuration
async getSambaGlobalConfig(): Promise<{ [key: string]: any }> {
const response = await this.client.get("/api/shares/samba/config")
return response.data
}
async setSambaGlobalConfig(config: string): Promise<{ status: string }> {
const response = await this.client.put("/api/shares/samba/config", { config })
return response.data
}
async getNfsGlobalConfig(): Promise<{ exports: string; path?: string }> {
const response = await this.client.get("/api/shares/nfs/config")
return response.data
}
async setNfsGlobalConfig(config: string): Promise<{ status: string }> {
const response = await this.client.put("/api/shares/nfs/config", { config })
return response.data
}
// System
async getSystemInfo(): Promise<SystemInfo> {
const response = await this.client.get("/api/system/info")
return response.data
}
async getMemory(): Promise<{ total: number; used: number; available: number; swap_total: number; swap_used: number }> {
const response = await this.client.get("/api/system/memory")
return response.data
}
async getCpuInfo(): Promise<{ count: number; percent?: number; load_average: number[] }> {
const response = await this.client.get("/api/system/cpu")
return response.data
}
async getUptime(): Promise<{ uptime_seconds: number; uptime_string: string }> {
const response = await this.client.get("/api/system/uptime")
return response.data
}
async getNetwork(): Promise<{ interfaces: any[] }> {
const response = await this.client.get("/api/system/network")
return response.data
}
async getNetworkTraffic(): Promise<{ interfaces: any[] }> {
const response = await this.client.get("/api/system/network/traffic")
return response.data
}
async getDiskIO(): Promise<{ disks: any[] }> {
const response = await this.client.get("/api/system/diskio")
return response.data
}
async getServices(): Promise<{ services: any[] }> {
const response = await this.client.get("/api/system/services")
return response.data
}
async getUnits(): Promise<{
services: any[]
targets: any[]
sockets: any[]
timers: any[]
paths: any[]
}> {
const response = await this.client.get("/api/system/units")
return response.data
}
async getSystemLogs(limit: number = 20): Promise<{ logs: string[] }> {
const response = await this.client.get(`/api/system/logs?limit=${limit}`)
return response.data
}
async getHealth(): Promise<{ status: string; version: string }> {
const response = await this.client.get("/health")
return response.data
}
// Identities - Users
async getUsers(): Promise<SystemUser[]> {
const response = await this.client.get("/api/identities/users")
return response.data.users ?? []
}
async createUser(username: string, home_dir?: string, shell?: string, gecos?: string): Promise<{ status: string }> {
const response = await this.client.post("/api/identities/users", { username, home_dir, shell, gecos })
return response.data
}
async deleteUser(username: string, remove_home: boolean = true): Promise<{ status: string }> {
const response = await this.client.delete(`/api/identities/users/${username}`, { params: { remove_home } })
return response.data
}
async changePassword(username: string, password: string): Promise<{ status: string }> {
const response = await this.client.post(`/api/identities/users/${username}/password`, { password })
return response.data
}
async changeShell(username: string, shell: string): Promise<{ status: string }> {
const response = await this.client.post(`/api/identities/users/${username}/shell`, { shell })
return response.data
}
async lockUser(username: string): Promise<{ status: string }> {
const response = await this.client.post(`/api/identities/users/${username}/lock`)
return response.data
}
async unlockUser(username: string): Promise<{ status: string }> {
const response = await this.client.post(`/api/identities/users/${username}/unlock`)
return response.data
}
async setSambaPassword(username: string, password: string): Promise<{ status: string }> {
const response = await this.client.post(`/api/identities/users/${username}/samba-password`, { password })
return response.data
}
// Identities - Groups
async getGroups(): Promise<SystemGroup[]> {
const response = await this.client.get("/api/identities/groups")
return response.data.groups ?? []
}
async createGroup(groupname: string): Promise<{ status: string }> {
const response = await this.client.post("/api/identities/groups", { groupname })
return response.data
}
async deleteGroup(groupname: string): Promise<{ status: string }> {
const response = await this.client.delete(`/api/identities/groups/${groupname}`)
return response.data
}
async addUserToGroup(username: string, groupname: string): Promise<{ status: string }> {
const response = await this.client.post(`/api/identities/users/${username}/groups`, { groupname })
return response.data
}
async removeUserFromGroup(username: string, groupname: string): Promise<{ status: string }> {
const response = await this.client.delete(`/api/identities/users/${username}/groups/${groupname}`)
return response.data
}
// Identities - Samba Users
async getSambaUsers(): Promise<SystemUser[]> {
const response = await this.client.get("/api/identities/samba-users")
return response.data.users ?? []
}
// Identities - Login History
async getLoginHistory(limit: number = 50): Promise<LoginEntry[]> {
const response = await this.client.get("/api/identities/login-history", { params: { limit } })
return response.data.logins ?? []
}
// Navigator - Copy, Move, Search
async copyFile(src: string, dst: string, overwrite: boolean = false): Promise<{ status: string }> {
const response = await this.client.post("/api/navigator/copy", { src, dst, overwrite })
return response.data
}
async moveFile(src: string, dst: string, overwrite: boolean = false): Promise<{ status: string }> {
const response = await this.client.post("/api/navigator/move", { src, dst, overwrite })
return response.data
}
async searchFiles(q: string, path: string = "", limit: number = 50): Promise<any[]> {
const response = await this.client.get("/api/navigator/search", { params: { q, path, limit } })
return response.data.results ?? []
}
}
export const api = new ZFSManagerAPI()
+65
View File
@@ -0,0 +1,65 @@
"use client"
import { useEffect, useRef, useCallback } from "react"
export type WsMessage = {
type: "pool_status" | "scrub_progress" | "snapshot_created" | "alert"
data: unknown
}
type Handler = (msg: WsMessage) => void
export function useWebSocket(onMessage: Handler) {
const wsRef = useRef<WebSocket | null>(null)
const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const delayRef = useRef(2000)
const handlerRef = useRef(onMessage)
handlerRef.current = onMessage
const connect = useCallback(() => {
if (typeof window === "undefined") return
const token = localStorage.getItem("access_token")
if (!token) return
// Derive WS URL from current page origin
const proto = window.location.protocol === "https:" ? "wss" : "ws"
const wsUrl = `${proto}://${window.location.host}/ws`
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
delayRef.current = 2000 // Reset backoff on success
}
ws.onmessage = (event) => {
try {
const msg: WsMessage = JSON.parse(event.data)
handlerRef.current(msg)
} catch {
// ignore malformed messages
}
}
ws.onclose = () => {
// Reconnect with exponential backoff (max 30s)
retryRef.current = setTimeout(() => {
delayRef.current = Math.min(delayRef.current * 2, 30000)
connect()
}, delayRef.current)
}
ws.onerror = () => {
ws.close()
}
}, [])
useEffect(() => {
connect()
return () => {
if (retryRef.current) clearTimeout(retryRef.current)
wsRef.current?.close()
}
}, [connect])
}
+68
View File
@@ -0,0 +1,68 @@
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return "0 Bytes"
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]
}
export function formatPercent(used: number, total: number): string {
if (total === 0) return "0%"
return ((used / total) * 100).toFixed(1) + "%"
}
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) {
return `${days}d ${hours}h ${minutes}m`
} else if (hours > 0) {
return `${hours}h ${minutes}m`
} else {
return `${minutes}m`
}
}
export function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000)
return date.toLocaleDateString() + " " + date.toLocaleTimeString()
}
export function getPoolHealthColor(health: string): string {
switch (health) {
case "ONLINE":
return "text-green-600"
case "DEGRADED":
return "text-yellow-600"
case "FAULTED":
case "OFFLINE":
case "UNAVAIL":
return "text-red-600"
default:
return "text-gray-600"
}
}
export function getPoolHealthBgColor(health: string): string {
switch (health) {
case "ONLINE":
return "bg-green-100"
case "DEGRADED":
return "bg-yellow-100"
case "FAULTED":
case "OFFLINE":
case "UNAVAIL":
return "bg-red-100"
default:
return "bg-gray-100"
}
}
export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(" ")
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+12
View File
@@ -0,0 +1,12 @@
const nextConfig = {
reactStrictMode: true,
// Static export for Raspberry Pi
output: 'export',
// Compression
compress: true,
}
module.exports = nextConfig
+5834
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "zmb-webui-frontend",
"version": "1.0.0",
"description": "ZMB Webui Web UI - Next.js Frontend",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"export": "next build && next export"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.3",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0",
"lucide-react": "^0.294.0",
"axios": "^1.6.0"
},
"devDependencies": {
"eslint": "^8.54.0",
"eslint-config-next": "^15.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+74
View File
@@ -0,0 +1,74 @@
const defaultTheme = require("tailwindcss/defaultTheme")
const config = {
darkMode: ["class"],
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./lib/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
fontFamily: {
sans: ["Inter", ...defaultTheme.fontFamily.sans],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [],
}
module.exports = config
+46
View File
@@ -0,0 +1,46 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": [
"./*"
]
},
"plugins": [
{
"name": "next"
}
],
"allowJs": true,
"noEmit": true,
"isolatedModules": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
File diff suppressed because one or more lines are too long