Remove: altes Next.js-Frontend gelöscht (durch HTMX+Jinja2 ersetzt)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy ZMB Webui to target system
|
||||||
|
# Usage: ./deploy.sh [target] [--pull]
|
||||||
|
# Example: ./deploy.sh 192.168.1.179
|
||||||
|
# Example: ./deploy.sh 192.168.1.179 --pull (pull from git first)
|
||||||
|
|
||||||
|
TARGET="${1:-192.168.1.179}"
|
||||||
|
PULL="${2:-}"
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$PULL" == "--pull" ]; then
|
||||||
|
echo "📦 Pulling from git..."
|
||||||
|
git pull origin master
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔨 Building frontend..."
|
||||||
|
cd frontend
|
||||||
|
rm -rf .next out
|
||||||
|
npm run build > /dev/null 2>&1
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "📤 Deploying to $TARGET..."
|
||||||
|
|
||||||
|
# Deploy backend
|
||||||
|
echo " → Backend..."
|
||||||
|
rsync -avz --delete backend/ root@$TARGET:/opt/zmb-webui/backend/ \
|
||||||
|
--exclude venv --exclude __pycache__ --exclude "*.pyc" > /dev/null
|
||||||
|
|
||||||
|
# Deploy frontend
|
||||||
|
echo " → Frontend..."
|
||||||
|
rsync -avz --delete frontend/out/ root@$TARGET:/opt/zmb-webui/frontend/ > /dev/null
|
||||||
|
|
||||||
|
# Restart services
|
||||||
|
echo "🔄 Restarting services..."
|
||||||
|
ssh root@$TARGET bash << 'EOF'
|
||||||
|
systemctl restart zmb-webui-backend
|
||||||
|
sleep 1
|
||||||
|
systemctl restart nginx
|
||||||
|
sleep 1
|
||||||
|
echo "✓ Services restarted"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✅ Deployment complete!"
|
||||||
|
echo " Access: https://$TARGET:8090"
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals"
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# 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
|
|
||||||
*~
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
"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 = await api.getSambaConfig().catch(() => ({ parameters: [] }))
|
|
||||||
|
|
||||||
// Convert parameters array to key=value format
|
|
||||||
const sambaStr = (samba.parameters ?? [])
|
|
||||||
.map((p: any) => `${p.key} = ${p.value}`)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
setSambaConfig(sambaStr)
|
|
||||||
setSambaConfigOriginal(sambaStr)
|
|
||||||
setNfsConfig("")
|
|
||||||
setNfsConfigOriginal("")
|
|
||||||
} 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)
|
|
||||||
|
|
||||||
// Parse key=value format to object
|
|
||||||
const parameters: { [key: string]: string } = {}
|
|
||||||
sambaConfig.split("\n").forEach((line) => {
|
|
||||||
const trimmed = line.trim()
|
|
||||||
if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith(";")) {
|
|
||||||
const [key, ...valueParts] = trimmed.split("=")
|
|
||||||
if (key) {
|
|
||||||
parameters[key.trim()] = valueParts.join("=").trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await api.setSambaConfig(parameters)
|
|
||||||
setSambaConfigOriginal(sambaConfig)
|
|
||||||
setSambaEditing(false)
|
|
||||||
setSuccess("Samba configuration saved successfully. Changes applied to registry.")
|
|
||||||
setTimeout(() => setSuccess(null), 3000)
|
|
||||||
// Reload to show updated values
|
|
||||||
setTimeout(() => loadConfigs(), 500)
|
|
||||||
} 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)
|
|
||||||
|
|
||||||
setSuccess("NFS editing coming soon")
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-muted-foreground mt-4 space-y-2">
|
|
||||||
<p>Format: <code className="bg-muted px-2 py-1 rounded">key = value</code></p>
|
|
||||||
<p>• Add new parameters on new lines</p>
|
|
||||||
<p>• Changes are applied immediately via net conf setparm</p>
|
|
||||||
<p>• Comments starting with # or ; are ignored</p>
|
|
||||||
<p>Example: <code className="bg-muted px-2 py-1 rounded">log level = 2</code></p>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,877 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from "react"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { api } from "@/lib/api"
|
|
||||||
import { Header } from "@/components/Header"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { RefreshCw, Filter, X } from "lucide-react"
|
|
||||||
|
|
||||||
type LogEntry = {
|
|
||||||
text: string
|
|
||||||
date?: Date
|
|
||||||
unit?: string
|
|
||||||
level?: "err" | "warning" | "info" | "debug"
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Logs() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [allLogs, setAllLogs] = useState<string[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [limit, setLimit] = useState(500)
|
|
||||||
|
|
||||||
// Filter states
|
|
||||||
const [timeRange, setTimeRange] = useState("all") // all, 24h, 7d, 30d
|
|
||||||
const [priority, setPriority] = useState("all") // all, err (error and higher)
|
|
||||||
const [unit, setUnit] = useState("") // Unit/Service filter
|
|
||||||
const [searchText, setSearchText] = useState("") // Free text search
|
|
||||||
const [units, setUnits] = useState<string[]>([]) // Available units for dropdown
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check authentication
|
|
||||||
const token = localStorage.getItem("access_token")
|
|
||||||
if (!token) {
|
|
||||||
router.push("/login")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load logs
|
|
||||||
loadLogs()
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Reload when limit changes
|
|
||||||
loadLogs()
|
|
||||||
}, [limit])
|
|
||||||
|
|
||||||
const loadLogs = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const data = await api.getSystemLogs(limit)
|
|
||||||
const logsList = data?.logs || []
|
|
||||||
setAllLogs(logsList)
|
|
||||||
|
|
||||||
// Extract unique units for dropdown
|
|
||||||
const uniqueUnits = new Set<string>()
|
|
||||||
logsList.forEach((log: string) => {
|
|
||||||
const match = log.match(/\s(\S+)\[(\d+)\]:|systemd(\[[\d.]+\])?:|(\S+):/)
|
|
||||||
if (match) {
|
|
||||||
const unitName = match[1] || match[3] || match[4] || ""
|
|
||||||
if (unitName && unitName !== "kernel") {
|
|
||||||
uniqueUnits.add(unitName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setUnits(Array.from(uniqueUnits).sort())
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load logs:", error)
|
|
||||||
setAllLogs([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse log entry to extract metadata
|
|
||||||
const parseLogEntry = (logText: string): LogEntry => {
|
|
||||||
const entry: LogEntry = { text: logText }
|
|
||||||
|
|
||||||
// Try to parse date from log (format: "MMM DD HH:MM:SS")
|
|
||||||
const dateMatch = logText.match(/^(\w+\s+\d+\s+\d{2}:\d{2}:\d{2})/)
|
|
||||||
if (dateMatch) {
|
|
||||||
try {
|
|
||||||
const now = new Date()
|
|
||||||
const dateStr = `${dateMatch[1]} ${now.getFullYear()}`
|
|
||||||
const parsed = new Date(dateStr)
|
|
||||||
if (!isNaN(parsed.getTime())) {
|
|
||||||
entry.date = parsed
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Date parsing failed, continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract unit/service name
|
|
||||||
const unitMatch = logText.match(/\s(\S+?)\[(\d+)\]:|systemd(\[[\d.]+\])?:|(\S+):/)
|
|
||||||
if (unitMatch) {
|
|
||||||
entry.unit = unitMatch[1] || unitMatch[3] || unitMatch[4] || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect priority level
|
|
||||||
if (logText.match(/ERROR|err|Err|ERR|error/i)) {
|
|
||||||
entry.level = "err"
|
|
||||||
} else if (logText.match(/WARN|warn|WARNING/i)) {
|
|
||||||
entry.level = "warning"
|
|
||||||
} else if (logText.match(/INFO|info|Notice|NOTICE/i)) {
|
|
||||||
entry.level = "info"
|
|
||||||
} else {
|
|
||||||
entry.level = "debug"
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter logs based on selected criteria
|
|
||||||
const filteredLogs = useMemo(() => {
|
|
||||||
return allLogs.filter((logText) => {
|
|
||||||
const entry = parseLogEntry(logText)
|
|
||||||
|
|
||||||
// Time filter
|
|
||||||
if (timeRange !== "all" && entry.date) {
|
|
||||||
let cutoffDate = new Date()
|
|
||||||
switch (timeRange) {
|
|
||||||
case "24h":
|
|
||||||
cutoffDate.setHours(cutoffDate.getHours() - 24)
|
|
||||||
break
|
|
||||||
case "7d":
|
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - 7)
|
|
||||||
break
|
|
||||||
case "30d":
|
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - 30)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (entry.date < cutoffDate) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority filter
|
|
||||||
if (priority === "err") {
|
|
||||||
if (entry.level !== "err") return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unit filter
|
|
||||||
if (unit && entry.unit) {
|
|
||||||
if (!entry.unit.toLowerCase().includes(unit.toLowerCase())) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text search filter
|
|
||||||
if (searchText) {
|
|
||||||
if (!logText.toLowerCase().includes(searchText.toLowerCase())) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}, [allLogs, timeRange, priority, unit, searchText])
|
|
||||||
|
|
||||||
const hasActiveFilters = timeRange !== "all" || priority !== "all" || unit || searchText
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<Header />
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">System Logs</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Showing {filteredLogs.length} of {allLogs.length} entries
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<select
|
|
||||||
value={limit}
|
|
||||||
onChange={(e) => setLimit(Number(e.target.value))}
|
|
||||||
className="px-3 py-2 rounded-md border border-border bg-background text-sm"
|
|
||||||
>
|
|
||||||
<option value={100}>Last 100</option>
|
|
||||||
<option value={200}>Last 200</option>
|
|
||||||
<option value={500}>Last 500</option>
|
|
||||||
<option value={1000}>Last 1000</option>
|
|
||||||
</select>
|
|
||||||
<Button onClick={loadLogs} disabled={loading}>
|
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter Section */}
|
|
||||||
<Card className="mb-6">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{/* Time Range Filter */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Letzte</label>
|
|
||||||
<select
|
|
||||||
value={timeRange}
|
|
||||||
onChange={(e) => setTimeRange(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Alle</option>
|
|
||||||
<option value="24h">24 Stunden</option>
|
|
||||||
<option value="7d">7 Tage</option>
|
|
||||||
<option value="30d">30 Tage</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Priority Filter */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Priorität</label>
|
|
||||||
<select
|
|
||||||
value={priority}
|
|
||||||
onChange={(e) => setPriority(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Alle</option>
|
|
||||||
<option value="err">Fehler und höher</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unit Filter */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Kennung</label>
|
|
||||||
<select
|
|
||||||
value={unit}
|
|
||||||
onChange={(e) => setUnit(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
|
||||||
>
|
|
||||||
<option value="">Alle Services</option>
|
|
||||||
{units.map((u) => (
|
|
||||||
<option key={u} value={u}>
|
|
||||||
{u}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Free Text Search */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Filter</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="z.B. priority:err"
|
|
||||||
value={searchText}
|
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Filters Display */}
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2 items-center">
|
|
||||||
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
|
||||||
<Filter className="w-4 h-4" /> Aktive Filter:
|
|
||||||
</span>
|
|
||||||
{timeRange !== "all" && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-primary/10 text-primary text-xs">
|
|
||||||
{timeRange === "24h"
|
|
||||||
? "Letzte 24h"
|
|
||||||
: timeRange === "7d"
|
|
||||||
? "Letzte 7 Tage"
|
|
||||||
: "Letzte 30 Tage"}
|
|
||||||
<button
|
|
||||||
onClick={() => setTimeRange("all")}
|
|
||||||
className="hover:text-primary/70"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{priority === "err" && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-red-500/10 text-red-600 text-xs">
|
|
||||||
Nur Fehler
|
|
||||||
<button
|
|
||||||
onClick={() => setPriority("all")}
|
|
||||||
className="hover:text-red-600/70"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{unit && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-500/10 text-blue-600 text-xs">
|
|
||||||
{unit}
|
|
||||||
<button onClick={() => setUnit("")} className="hover:text-blue-600/70">
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{searchText && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-purple-500/10 text-purple-600 text-xs">
|
|
||||||
"{searchText}"
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchText("")}
|
|
||||||
className="hover:text-purple-600/70"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logs Display */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="bg-muted/30 rounded p-4 font-mono text-xs space-y-1 max-h-[calc(100vh-300px)] overflow-y-auto">
|
|
||||||
{filteredLogs.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground text-center py-8">
|
|
||||||
{loading ? "Loading logs..." : "No logs found matching filters"}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredLogs.map((log: string, idx: number) => {
|
|
||||||
const entry = parseLogEntry(log)
|
|
||||||
const bgColor =
|
|
||||||
entry.level === "err"
|
|
||||||
? "bg-red-500/5 hover:bg-red-500/10"
|
|
||||||
: entry.level === "warning"
|
|
||||||
? "bg-yellow-500/5 hover:bg-yellow-500/10"
|
|
||||||
: "hover:bg-muted/50"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className={`text-muted-foreground px-2 py-1 rounded transition-colors ${bgColor}`}
|
|
||||||
>
|
|
||||||
{log}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,646 +0,0 @@
|
|||||||
"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)
|
|
||||||
const [diskUsage, setDiskUsage] = 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", "TB", "PB"]
|
|
||||||
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, diskusage] = 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),
|
|
||||||
api.getDiskUsage().catch(() => null),
|
|
||||||
])
|
|
||||||
setSystemInfo(sysInfo)
|
|
||||||
setMemoryInfo(memInfo)
|
|
||||||
setCpuInfo(cpuData)
|
|
||||||
setUptimeInfo(uptime)
|
|
||||||
setNetworkInfo(network)
|
|
||||||
setNetworkTraffic(traffic)
|
|
||||||
setDiskIO(diskio)
|
|
||||||
setDiskUsage(diskusage)
|
|
||||||
|
|
||||||
// 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 ?? 0).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 ?? 0).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 Usage (df-based, immer sichtbar wenn Daten vorhanden) */}
|
|
||||||
{diskUsage?.filesystems && diskUsage.filesystems.length > 0 && (
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Disk Usage</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{diskUsage.filesystems.map((fs: any) => {
|
|
||||||
const pct = fs.capacity
|
|
||||||
const barColor = pct > 85 ? "bg-red-500" : pct > 70 ? "bg-yellow-500" : "bg-blue-500"
|
|
||||||
return (
|
|
||||||
<Card key={fs.mountpoint}>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
||||||
<HardDrive className="w-4 h-4" />
|
|
||||||
{fs.mountpoint}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
|
||||||
<div className={`h-full ${barColor} rounded-full`} style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground w-9 text-right">{pct}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Total</p>
|
|
||||||
<p className="font-medium">{formatBytes(fs.total)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Used</p>
|
|
||||||
<p className="font-medium">{formatBytes(fs.used)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Free</p>
|
|
||||||
<p className="font-medium">{formatBytes(fs.available)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">{fs.filesystem}</p>
|
|
||||||
</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("/zfs")}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from "react"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { api } from "@/lib/api"
|
|
||||||
import { Header } from "@/components/Header"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { RefreshCw, X, Zap, Target, Wifi, Clock, FolderOpen } from "lucide-react"
|
|
||||||
|
|
||||||
type Unit = {
|
|
||||||
name: string
|
|
||||||
active: string
|
|
||||||
sub: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type UnitType = "services" | "targets" | "sockets" | "timers" | "paths"
|
|
||||||
|
|
||||||
export default function Services() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [units, setUnits] = useState<Record<UnitType, Unit[]>>({
|
|
||||||
services: [],
|
|
||||||
targets: [],
|
|
||||||
sockets: [],
|
|
||||||
timers: [],
|
|
||||||
paths: [],
|
|
||||||
})
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
// Filter states
|
|
||||||
const [activeTab, setActiveTab] = useState<UnitType>("services")
|
|
||||||
const [searchText, setSearchText] = useState("")
|
|
||||||
const [activeStatus, setActiveStatus] = useState("all") // all, active, inactive
|
|
||||||
const [fileStatus, setFileStatus] = useState("all") // all, enabled, disabled, static
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const token = localStorage.getItem("access_token")
|
|
||||||
if (!token) {
|
|
||||||
router.push("/login")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loadUnits()
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
const loadUnits = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const data = await api.getUnits()
|
|
||||||
setUnits(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load units:", error)
|
|
||||||
setUnits({
|
|
||||||
services: [],
|
|
||||||
targets: [],
|
|
||||||
sockets: [],
|
|
||||||
timers: [],
|
|
||||||
paths: [],
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter current tab's units
|
|
||||||
const filteredUnits = useMemo(() => {
|
|
||||||
let filtered = units[activeTab] || []
|
|
||||||
|
|
||||||
// Search filter (name or description)
|
|
||||||
if (searchText) {
|
|
||||||
filtered = filtered.filter((unit) => {
|
|
||||||
const searchLower = searchText.toLowerCase()
|
|
||||||
return (
|
|
||||||
unit.name.toLowerCase().includes(searchLower) ||
|
|
||||||
unit.description.toLowerCase().includes(searchLower)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active status filter
|
|
||||||
if (activeStatus === "active") {
|
|
||||||
filtered = filtered.filter((unit) => unit.active === "active")
|
|
||||||
} else if (activeStatus === "inactive") {
|
|
||||||
filtered = filtered.filter((unit) => unit.active === "inactive")
|
|
||||||
}
|
|
||||||
|
|
||||||
// File status filter
|
|
||||||
if (fileStatus !== "all") {
|
|
||||||
filtered = filtered.filter((unit) => {
|
|
||||||
const sub = unit.sub.toLowerCase()
|
|
||||||
if (fileStatus === "enabled") {
|
|
||||||
return sub === "enabled"
|
|
||||||
} else if (fileStatus === "disabled") {
|
|
||||||
return sub === "disabled"
|
|
||||||
} else if (fileStatus === "static") {
|
|
||||||
return sub === "static"
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
}, [units, activeTab, searchText, activeStatus, fileStatus])
|
|
||||||
|
|
||||||
const tabConfig: Record<
|
|
||||||
UnitType,
|
|
||||||
{ label: string; icon: React.ReactNode; count: number }
|
|
||||||
> = {
|
|
||||||
services: {
|
|
||||||
label: "Dienste",
|
|
||||||
icon: <Zap className="w-4 h-4" />,
|
|
||||||
count: units.services.length,
|
|
||||||
},
|
|
||||||
targets: {
|
|
||||||
label: "Ziele",
|
|
||||||
icon: <Target className="w-4 h-4" />,
|
|
||||||
count: units.targets.length,
|
|
||||||
},
|
|
||||||
sockets: {
|
|
||||||
label: "Sockets",
|
|
||||||
icon: <Wifi className="w-4 h-4" />,
|
|
||||||
count: units.sockets.length,
|
|
||||||
},
|
|
||||||
timers: {
|
|
||||||
label: "Timer",
|
|
||||||
icon: <Clock className="w-4 h-4" />,
|
|
||||||
count: units.timers.length,
|
|
||||||
},
|
|
||||||
paths: {
|
|
||||||
label: "Pfade",
|
|
||||||
icon: <FolderOpen className="w-4 h-4" />,
|
|
||||||
count: units.paths.length,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
if (status === "active") {
|
|
||||||
return <Badge className="bg-green-600 hover:bg-green-700">Aktiv</Badge>
|
|
||||||
}
|
|
||||||
return <Badge variant="secondary">Inaktiv</Badge>
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSubStatusBadge = (sub: string) => {
|
|
||||||
const subLower = sub.toLowerCase()
|
|
||||||
if (subLower === "running") {
|
|
||||||
return <Badge className="bg-blue-600 hover:bg-blue-700">Läuft</Badge>
|
|
||||||
} else if (subLower === "enabled") {
|
|
||||||
return <Badge className="bg-green-500 hover:bg-green-600">Aktiviert</Badge>
|
|
||||||
} else if (subLower === "disabled") {
|
|
||||||
return <Badge variant="secondary">Deaktiviert</Badge>
|
|
||||||
} else if (subLower === "static") {
|
|
||||||
return <Badge variant="outline">Statisch</Badge>
|
|
||||||
}
|
|
||||||
return <Badge variant="outline">{sub}</Badge>
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasActiveFilters = searchText || activeStatus !== "all" || fileStatus !== "all"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<Header />
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Systemd Einheiten</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Dienste, Ziele, Sockets, Timer und Pfade
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={loadUnits} disabled={loading}>
|
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
|
||||||
Aktualisieren
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="mb-6 flex flex-wrap gap-2">
|
|
||||||
{(Object.keys(tabConfig) as UnitType[]).map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab(tab)
|
|
||||||
setSearchText("")
|
|
||||||
setActiveStatus("all")
|
|
||||||
setFileStatus("all")
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors ${
|
|
||||||
activeTab === tab
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "border border-border hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tabConfig[tab].icon}
|
|
||||||
{tabConfig[tab].label}
|
|
||||||
<span className="text-xs ml-1 opacity-75">
|
|
||||||
({tabConfig[tab].count})
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter Section */}
|
|
||||||
<Card className="mb-6">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{/* Search Filter */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
Nach Name oder Beschreibung
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="z.B. ssh, apache..."
|
|
||||||
value={searchText}
|
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Status Filter */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
Aktiver Status
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={activeStatus}
|
|
||||||
onChange={(e) => setActiveStatus(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Alle</option>
|
|
||||||
<option value="active">Aktiv</option>
|
|
||||||
<option value="inactive">Inaktiv</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File Status Filter */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
Dateistatus
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={fileStatus}
|
|
||||||
onChange={(e) => setFileStatus(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Alle</option>
|
|
||||||
<option value="enabled">Aktiviert</option>
|
|
||||||
<option value="disabled">Deaktiviert</option>
|
|
||||||
<option value="static">Statisch</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Filters Display */}
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2 items-center">
|
|
||||||
<span className="text-sm text-muted-foreground">Filter:</span>
|
|
||||||
{searchText && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-purple-500/10 text-purple-600 text-xs">
|
|
||||||
"{searchText}"
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchText("")}
|
|
||||||
className="hover:text-purple-600/70"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{activeStatus !== "all" && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-green-500/10 text-green-600 text-xs">
|
|
||||||
{activeStatus === "active" ? "Aktiv" : "Inaktiv"}
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveStatus("all")}
|
|
||||||
className="hover:text-green-600/70"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{fileStatus !== "all" && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-500/10 text-blue-600 text-xs">
|
|
||||||
{fileStatus === "enabled"
|
|
||||||
? "Aktiviert"
|
|
||||||
: fileStatus === "disabled"
|
|
||||||
? "Deaktiviert"
|
|
||||||
: "Statisch"}
|
|
||||||
<button
|
|
||||||
onClick={() => setFileStatus("all")}
|
|
||||||
className="hover:text-blue-600/70"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Units Table */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b border-border bg-muted/30">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left py-3 px-4 font-medium">Name</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium">
|
|
||||||
Aktiver Status
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium">
|
|
||||||
Dateistatus
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium">
|
|
||||||
Beschreibung
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredUnits.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={4}
|
|
||||||
className="py-8 px-4 text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
{loading ? "Lädt..." : "Keine Einheiten gefunden"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredUnits.map((unit, idx) => (
|
|
||||||
<tr
|
|
||||||
key={idx}
|
|
||||||
className="border-b border-border/50 hover:bg-muted/30"
|
|
||||||
>
|
|
||||||
<td className="py-3 px-4 font-mono text-xs">
|
|
||||||
{unit.name}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
{getStatusBadge(unit.active)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
{getSubStatusBadge(unit.sub)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-xs text-muted-foreground truncate">
|
|
||||||
{unit.description || "—"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="px-4 py-2 text-xs text-muted-foreground border-t">
|
|
||||||
Zeige {filteredUnits.length} von {units[activeTab]?.length || 0}{" "}
|
|
||||||
{tabConfig[activeTab].label.toLowerCase()}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,516 +0,0 @@
|
|||||||
"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" | "config">("samba")
|
|
||||||
const [sambaShares, setSambaShares] = useState<any[]>([])
|
|
||||||
const [nfsShares, setNfsShares] = useState<any[]>([])
|
|
||||||
const [sambaConfig, setSambaConfig] = 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)
|
|
||||||
const [editMode, setEditMode] = useState(false)
|
|
||||||
const [rawConfigText, setRawConfigText] = useState("")
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [macosEnabled, setMacosEnabled] = useState(false)
|
|
||||||
const [editingShare, setEditingShare] = useState<any | null>(null)
|
|
||||||
|
|
||||||
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, config] = await Promise.all([
|
|
||||||
api.getSambaShares().catch(() => []),
|
|
||||||
api.getNfsShares().catch(() => []),
|
|
||||||
api.getSambaConfig().catch(() => ({ parameters: [] })),
|
|
||||||
])
|
|
||||||
|
|
||||||
setSambaShares(samba)
|
|
||||||
setNfsShares(nfs)
|
|
||||||
const params: { key: string; value: string }[] = config.parameters || []
|
|
||||||
setSambaConfig(params)
|
|
||||||
const raw = params.map((p) => `${p.key} = ${p.value}`).join("\n")
|
|
||||||
setRawConfigText(raw)
|
|
||||||
const hasMacOS = ["fruit:encoding", "fruit:metadata", "fruit:zero_file_id", "fruit:nfs_aces"].every(
|
|
||||||
(k) => params.some((p) => p.key === k)
|
|
||||||
)
|
|
||||||
setMacosEnabled(hasMacOS)
|
|
||||||
} 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 handleSaveShare = async () => {
|
|
||||||
if (!editingShare) return
|
|
||||||
try {
|
|
||||||
setSaving(true)
|
|
||||||
await api.updateSambaShare(editingShare.oldName, {
|
|
||||||
name: editingShare.name,
|
|
||||||
path: editingShare.path,
|
|
||||||
comment: editingShare.comment
|
|
||||||
})
|
|
||||||
setSambaShares(sambaShares.map(s =>
|
|
||||||
s.name === editingShare.oldName
|
|
||||||
? { name: editingShare.name, path: editingShare.path, comment: editingShare.comment, ...s }
|
|
||||||
: s
|
|
||||||
))
|
|
||||||
setEditingShare(null)
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to save share")
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNfsCreated = (newShare: any) => {
|
|
||||||
setNfsShares([...nfsShares, newShare])
|
|
||||||
setShowNfsDialog(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const MACOS_PARAMS: { [key: string]: string } = {
|
|
||||||
"fruit:encoding": "native",
|
|
||||||
"fruit:metadata": "stream",
|
|
||||||
"fruit:zero_file_id": "yes",
|
|
||||||
"fruit:nfs_aces": "no",
|
|
||||||
}
|
|
||||||
|
|
||||||
const MACOS_KEYS = Object.keys(MACOS_PARAMS)
|
|
||||||
|
|
||||||
const handleSaveRaw = async () => {
|
|
||||||
try {
|
|
||||||
setSaving(true)
|
|
||||||
const parsed: { [key: string]: string } = {}
|
|
||||||
rawConfigText.split("\n").forEach((line) => {
|
|
||||||
const eq = line.indexOf("=")
|
|
||||||
if (eq === -1) return
|
|
||||||
const key = line.slice(0, eq).trim()
|
|
||||||
const value = line.slice(eq + 1).trim()
|
|
||||||
if (key) parsed[key] = value
|
|
||||||
})
|
|
||||||
await api.setSambaConfig(parsed)
|
|
||||||
await loadShares()
|
|
||||||
setEditMode(false)
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to save configuration")
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleMacOS = async (enable: boolean) => {
|
|
||||||
try {
|
|
||||||
setSaving(true)
|
|
||||||
const current = sambaConfig.reduce((acc, p) => { acc[p.key] = p.value; return acc }, {} as { [key: string]: string })
|
|
||||||
if (enable) {
|
|
||||||
await api.setSambaConfig({ ...current, ...MACOS_PARAMS } as { [key: string]: string })
|
|
||||||
} else {
|
|
||||||
const filtered: { [key: string]: string } = {}
|
|
||||||
Object.entries(current).forEach(([k, v]) => { if (!MACOS_KEYS.includes(k)) filtered[k] = v as string })
|
|
||||||
await api.setSambaConfig(filtered)
|
|
||||||
}
|
|
||||||
setMacosEnabled(enable)
|
|
||||||
await loadShares()
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to update macOS settings")
|
|
||||||
} finally {
|
|
||||||
setSaving(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>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("config")}
|
|
||||||
className={`px-4 py-2 font-medium transition-colors ${
|
|
||||||
activeTab === "config"
|
|
||||||
? "border-b-2 border-primary text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Samba Config
|
|
||||||
</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) => {
|
|
||||||
const isEditing = editingShare?.oldName === share.name
|
|
||||||
return (
|
|
||||||
<tr key={share.name} className="border-b border-border/50 hover:bg-muted/30">
|
|
||||||
<td className="py-3 px-4 text-xs">
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editingShare.name}
|
|
||||||
onChange={(e) => setEditingShare({ ...editingShare, name: e.target.value })}
|
|
||||||
className="px-2 py-1 rounded border border-border bg-background text-xs font-mono w-full"
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="font-mono">{share.name}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-xs">
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editingShare.path}
|
|
||||||
onChange={(e) => setEditingShare({ ...editingShare, path: e.target.value })}
|
|
||||||
className="px-2 py-1 rounded border border-border bg-background text-xs font-mono w-full"
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="font-mono">{share.path}</span>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editingShare.comment || ""}
|
|
||||||
onChange={(e) => setEditingShare({ ...editingShare, comment: e.target.value })}
|
|
||||||
className="px-2 py-1 rounded border border-border bg-background text-xs w-full"
|
|
||||||
disabled={saving}
|
|
||||||
placeholder="Optional comment"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
share.comment || "—"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-right space-x-2">
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveShare}
|
|
||||||
className="text-green-600 hover:text-green-700 transition-colors text-xs font-medium"
|
|
||||||
disabled={saving}
|
|
||||||
title="Save"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingShare(null)}
|
|
||||||
className="text-gray-600 hover:text-gray-700 transition-colors text-xs font-medium"
|
|
||||||
disabled={saving}
|
|
||||||
title="Cancel"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingShare({ ...share, oldName: share.name })}
|
|
||||||
className="text-blue-600 hover:text-blue-700 transition-colors"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* SAMBA CONFIG TAB */}
|
|
||||||
{activeTab === "config" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* MacOS Toggle */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Global MacOS Shares</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">Optimize all shares for MacOS</p>
|
|
||||||
{macosEnabled && (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{Object.entries(MACOS_PARAMS).map(([k, v]) => (
|
|
||||||
<span key={k} className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
|
||||||
{k} = {v}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
role="switch"
|
|
||||||
aria-checked={macosEnabled}
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => handleToggleMacOS(!macosEnabled)}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${macosEnabled ? "bg-red-500" : "bg-muted-foreground/30"} disabled:opacity-50`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${macosEnabled ? "translate-x-6" : "translate-x-1"}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Advanced raw config */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>Advanced</CardTitle>
|
|
||||||
{!editMode ? (
|
|
||||||
<Button size="sm" onClick={() => setEditMode(true)} variant="outline">
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" onClick={handleSaveRaw} disabled={saving}>
|
|
||||||
{saving ? "Saving..." : "Apply"}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={() => setEditMode(false)} variant="outline" disabled={saving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<textarea
|
|
||||||
readOnly={!editMode}
|
|
||||||
value={rawConfigText}
|
|
||||||
onChange={(e) => setRawConfigText(e.target.value)}
|
|
||||||
rows={20}
|
|
||||||
spellCheck={false}
|
|
||||||
className={`w-full font-mono text-xs rounded border px-3 py-2 bg-background resize-y focus:outline-none focus:ring-2 focus:ring-ring ${editMode ? "border-border" : "border-transparent text-muted-foreground"}`}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,985 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react"
|
|
||||||
import { api, Pool, PoolStatus, Dataset, Snapshot } from "@/lib/api"
|
|
||||||
import { Header } from "@/components/Header"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Dialog } from "@/components/ui/dialog"
|
|
||||||
import {
|
|
||||||
RefreshCw,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
MoreVertical,
|
|
||||||
Camera,
|
|
||||||
Plus,
|
|
||||||
HardDrive,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
type PoolTab = "filesystems" | "snapshots" | "status"
|
|
||||||
|
|
||||||
interface PoolRowState {
|
|
||||||
expanded: boolean
|
|
||||||
tab: PoolTab
|
|
||||||
status: PoolStatus | null
|
|
||||||
datasets: Dataset[]
|
|
||||||
snapshots: Snapshot[]
|
|
||||||
loadingStatus: boolean
|
|
||||||
loadingDatasets: boolean
|
|
||||||
loadingSnapshots: boolean
|
|
||||||
expandedSnapGroups: Set<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (!bytes || bytes === 0) return "0 B"
|
|
||||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"]
|
|
||||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
|
||||||
return (bytes / Math.pow(1024, i)).toFixed(2) + " " + units[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
function HealthBadge({ health }: { health: string }) {
|
|
||||||
const color =
|
|
||||||
health === "ONLINE"
|
|
||||||
? "text-green-600"
|
|
||||||
: health === "DEGRADED"
|
|
||||||
? "text-yellow-500"
|
|
||||||
: "text-red-500"
|
|
||||||
return (
|
|
||||||
<span className={`flex items-center gap-1 font-medium text-sm ${color}`}>
|
|
||||||
<span className="w-2 h-2 rounded-full bg-current inline-block" />
|
|
||||||
{health}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsageBar({ alloc, size }: { alloc: number; size: number }) {
|
|
||||||
const pct = size > 0 ? Math.min((alloc / size) * 100, 100) : 0
|
|
||||||
const color = pct > 85 ? "bg-red-500" : pct > 70 ? "bg-yellow-500" : "bg-blue-500"
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 min-w-[120px]">
|
|
||||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
|
||||||
<div className={`h-full ${color} rounded-full`} style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground w-9 text-right">{pct.toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmartData = {
|
|
||||||
model?: string
|
|
||||||
serial?: string
|
|
||||||
protocol?: string
|
|
||||||
power_on_hours?: number
|
|
||||||
temperature?: number
|
|
||||||
passed?: boolean
|
|
||||||
reallocated_sectors?: number
|
|
||||||
pending_sectors?: number
|
|
||||||
uncorrectable?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function DiskRow({
|
|
||||||
v,
|
|
||||||
depth,
|
|
||||||
poolName,
|
|
||||||
onDiskMenu,
|
|
||||||
}: {
|
|
||||||
v: any
|
|
||||||
depth: number
|
|
||||||
poolName: string
|
|
||||||
onDiskMenu: (e: React.MouseEvent, disk: string, poolName: string) => void
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
const [smart, setSmart] = useState<SmartData | null>(null)
|
|
||||||
const [loadingSmart, setLoadingSmart] = useState(false)
|
|
||||||
|
|
||||||
const toggleSmart = async () => {
|
|
||||||
if (!expanded && smart === null) {
|
|
||||||
setLoadingSmart(true)
|
|
||||||
try {
|
|
||||||
const data = await api.getDiskSmart(v.name)
|
|
||||||
setSmart(data)
|
|
||||||
} catch {
|
|
||||||
setSmart({})
|
|
||||||
} finally {
|
|
||||||
setLoadingSmart(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setExpanded((x) => !x)
|
|
||||||
}
|
|
||||||
|
|
||||||
const diskLabel = v.disk_id
|
|
||||||
? v.disk_id.replace(/^(ata|nvme|scsi|wwn)-/, "").replace(/_/g, " ").replace(/-[A-Z0-9]{12,}$/, "").trim()
|
|
||||||
: null
|
|
||||||
|
|
||||||
const tempColor =
|
|
||||||
smart?.temperature == null
|
|
||||||
? ""
|
|
||||||
: smart.temperature >= 55
|
|
||||||
? "text-red-500"
|
|
||||||
: smart.temperature >= 45
|
|
||||||
? "text-yellow-500"
|
|
||||||
: "text-green-600"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<tr className="border-b border-border/40 hover:bg-muted/20">
|
|
||||||
<td className="px-4 py-2 font-mono text-xs" style={{ paddingLeft: `${depth * 20 + 16}px` }}>
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-1.5 hover:text-foreground text-left"
|
|
||||||
onClick={toggleSmart}
|
|
||||||
title="SMART details"
|
|
||||||
>
|
|
||||||
{expanded ? <ChevronDown className="w-3 h-3 flex-shrink-0" /> : <ChevronRight className="w-3 h-3 flex-shrink-0" />}
|
|
||||||
<span>{v.name}</span>
|
|
||||||
{diskLabel && (
|
|
||||||
<span className="text-muted-foreground font-sans ml-1 truncate max-w-[160px]" title={v.disk_id}>
|
|
||||||
{diskLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<HealthBadge health={v.state || "—"} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-center">{v.read ?? 0}</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-center">{v.write ?? 0}</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-center">{v.cksum ?? 0}</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-muted-foreground">
|
|
||||||
{smart?.temperature != null && (
|
|
||||||
<span className={`font-medium ${tempColor}`}>{smart.temperature}°C</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-muted-foreground">
|
|
||||||
{smart?.passed != null && (
|
|
||||||
<span className={smart.passed ? "text-green-600" : "text-red-500"}>
|
|
||||||
{smart.passed ? "PASSED" : "FAILED"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-right">
|
|
||||||
<button
|
|
||||||
className="p-1 rounded hover:bg-muted"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onDiskMenu(e, v.name, poolName) }}
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{expanded && (
|
|
||||||
<tr className="border-b border-border/40 bg-muted/10">
|
|
||||||
<td colSpan={8} className="px-4 py-3" style={{ paddingLeft: `${depth * 20 + 36}px` }}>
|
|
||||||
{loadingSmart ? (
|
|
||||||
<span className="text-xs text-muted-foreground animate-pulse">Loading SMART data…</span>
|
|
||||||
) : smart && Object.keys(smart).length > 0 ? (
|
|
||||||
<div className="text-xs text-muted-foreground flex flex-wrap gap-x-6 gap-y-1">
|
|
||||||
{smart.model && <span><span className="text-foreground font-medium">Model:</span> {smart.model}</span>}
|
|
||||||
{smart.serial && <span><span className="text-foreground font-medium">Serial:</span> {smart.serial}</span>}
|
|
||||||
{smart.power_on_hours != null && (
|
|
||||||
<span><span className="text-foreground font-medium">Power-On:</span> {smart.power_on_hours.toLocaleString()} h</span>
|
|
||||||
)}
|
|
||||||
{smart.reallocated_sectors != null && (
|
|
||||||
<span className={smart.reallocated_sectors > 0 ? "text-red-500" : ""}>
|
|
||||||
<span className="text-foreground font-medium">Reallocated:</span> {smart.reallocated_sectors}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{smart.pending_sectors != null && (
|
|
||||||
<span className={smart.pending_sectors > 0 ? "text-yellow-500" : ""}>
|
|
||||||
<span className="text-foreground font-medium">Pending:</span> {smart.pending_sectors}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{smart.uncorrectable != null && (
|
|
||||||
<span className={smart.uncorrectable > 0 ? "text-red-500" : ""}>
|
|
||||||
<span className="text-foreground font-medium">Uncorrectable:</span> {smart.uncorrectable}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">No SMART data available</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function VdevTree({
|
|
||||||
vdevs,
|
|
||||||
depth = 0,
|
|
||||||
poolName,
|
|
||||||
onDiskMenu,
|
|
||||||
}: {
|
|
||||||
vdevs: any[]
|
|
||||||
depth?: number
|
|
||||||
poolName: string
|
|
||||||
onDiskMenu: (e: React.MouseEvent, disk: string, poolName: string) => void
|
|
||||||
}) {
|
|
||||||
const isDisk = (v: any) => !v.children || v.children.length === 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{vdevs.map((v, i) => (
|
|
||||||
isDisk(v) ? (
|
|
||||||
<DiskRow key={`${depth}-${i}-${v.name}`} v={v} depth={depth} poolName={poolName} onDiskMenu={onDiskMenu} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<tr key={`${depth}-${i}-${v.name}`} className="border-b border-border/40 hover:bg-muted/20">
|
|
||||||
<td className="px-4 py-2 font-mono text-xs font-medium" style={{ paddingLeft: `${depth * 20 + 16}px` }}>
|
|
||||||
{v.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<HealthBadge health={v.state || "—"} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-center">{v.read ?? 0}</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-center">{v.write ?? 0}</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-center">{v.cksum ?? 0}</td>
|
|
||||||
<td /><td /><td />
|
|
||||||
</tr>
|
|
||||||
<VdevTree vdevs={v.children} depth={depth + 1} poolName={poolName} onDiskMenu={onDiskMenu} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ZfsPage() {
|
|
||||||
const [pools, setPools] = useState<Pool[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
|
|
||||||
const [poolStates, setPoolStates] = useState<Map<string, PoolRowState>>(new Map())
|
|
||||||
|
|
||||||
// Pool context menu
|
|
||||||
const [poolMenu, setPoolMenu] = useState<{ pool: Pool; x: number; y: number } | null>(null)
|
|
||||||
// Disk context menu
|
|
||||||
const [diskMenu, setDiskMenu] = useState<{ disk: string; poolName: string; x: number; y: number } | null>(null)
|
|
||||||
|
|
||||||
// Dialogs
|
|
||||||
const [showCreateFilesystem, setShowCreateFilesystem] = useState(false)
|
|
||||||
const [createFsPool, setCreateFsPool] = useState("")
|
|
||||||
const [newFsName, setNewFsName] = useState("")
|
|
||||||
const [snapContextMenu, setSnapContextMenu] = useState<{ snap: Snapshot; x: number; y: number } | null>(null)
|
|
||||||
const [renameTarget, setRenameTarget] = useState<Snapshot | null>(null)
|
|
||||||
const [renameValue, setRenameValue] = useState("")
|
|
||||||
const [cloneTarget, setCloneTarget] = useState<Snapshot | null>(null)
|
|
||||||
const [cloneValue, setCloneValue] = useState("")
|
|
||||||
|
|
||||||
const loadPools = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const data = await api.getPools()
|
|
||||||
setPools(Array.isArray(data) ? data : [])
|
|
||||||
setLastRefresh(new Date())
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load pools:", err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadPools()
|
|
||||||
}, [loadPools])
|
|
||||||
|
|
||||||
const updatePoolState = (name: string, patch: Partial<PoolRowState>) => {
|
|
||||||
setPoolStates((prev) => {
|
|
||||||
const next = new Map(prev)
|
|
||||||
const cur = next.get(name) ?? {
|
|
||||||
expanded: false,
|
|
||||||
tab: "filesystems",
|
|
||||||
status: null,
|
|
||||||
datasets: [],
|
|
||||||
snapshots: [],
|
|
||||||
loadingStatus: false,
|
|
||||||
loadingDatasets: false,
|
|
||||||
loadingSnapshots: false,
|
|
||||||
expandedSnapGroups: new Set(),
|
|
||||||
}
|
|
||||||
next.set(name, { ...cur, ...patch })
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const togglePool = async (pool: Pool) => {
|
|
||||||
const cur = poolStates.get(pool.name)
|
|
||||||
const isExpanded = cur?.expanded ?? false
|
|
||||||
|
|
||||||
if (!isExpanded) {
|
|
||||||
updatePoolState(pool.name, { expanded: true, tab: "filesystems", loadingDatasets: true })
|
|
||||||
try {
|
|
||||||
const ds = await api.getDatasets(pool.name)
|
|
||||||
updatePoolState(pool.name, { datasets: ds, loadingDatasets: false })
|
|
||||||
} catch {
|
|
||||||
updatePoolState(pool.name, { loadingDatasets: false })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updatePoolState(pool.name, { expanded: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const switchTab = async (poolName: string, tab: PoolTab) => {
|
|
||||||
updatePoolState(poolName, { tab })
|
|
||||||
const cur = poolStates.get(poolName)
|
|
||||||
|
|
||||||
if (tab === "status" && !cur?.status) {
|
|
||||||
updatePoolState(poolName, { loadingStatus: true })
|
|
||||||
try {
|
|
||||||
const s = await api.getPoolStatus(poolName)
|
|
||||||
updatePoolState(poolName, { status: s, loadingStatus: false })
|
|
||||||
} catch {
|
|
||||||
updatePoolState(poolName, { loadingStatus: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab === "snapshots" && (!cur?.snapshots || cur.snapshots.length === 0)) {
|
|
||||||
updatePoolState(poolName, { loadingSnapshots: true })
|
|
||||||
try {
|
|
||||||
const snaps = await api.getSnapshots(poolName, 200)
|
|
||||||
updatePoolState(poolName, { snapshots: snaps, loadingSnapshots: false })
|
|
||||||
} catch {
|
|
||||||
updatePoolState(poolName, { loadingSnapshots: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshPoolStatus = async (poolName: string) => {
|
|
||||||
updatePoolState(poolName, { loadingStatus: true })
|
|
||||||
try {
|
|
||||||
const s = await api.getPoolStatus(poolName)
|
|
||||||
updatePoolState(poolName, { status: s, loadingStatus: false })
|
|
||||||
} catch {
|
|
||||||
updatePoolState(poolName, { loadingStatus: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshSnapshots = async (poolName: string) => {
|
|
||||||
updatePoolState(poolName, { loadingSnapshots: true })
|
|
||||||
try {
|
|
||||||
const snaps = await api.getSnapshots(poolName, 200)
|
|
||||||
updatePoolState(poolName, { snapshots: snaps, loadingSnapshots: false })
|
|
||||||
} catch {
|
|
||||||
updatePoolState(poolName, { loadingSnapshots: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSnapGroup = (poolName: string, groupKey: string) => {
|
|
||||||
setPoolStates((prev) => {
|
|
||||||
const next = new Map(prev)
|
|
||||||
const cur = next.get(poolName)
|
|
||||||
if (!cur) return prev
|
|
||||||
const groups = new Set(cur.expandedSnapGroups)
|
|
||||||
if (groups.has(groupKey)) groups.delete(groupKey)
|
|
||||||
else groups.add(groupKey)
|
|
||||||
next.set(poolName, { ...cur, expandedSnapGroups: groups })
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSnapAction = async (action: string, snap: Snapshot, poolName: string) => {
|
|
||||||
setSnapContextMenu(null)
|
|
||||||
if (action === "rollback") {
|
|
||||||
if (!confirm(`Roll back ${snap.dataset} to ${snap.name.split("@")[1]}? Data after this snapshot will be destroyed!`)) return
|
|
||||||
await api.rollbackSnapshot(snap.name)
|
|
||||||
refreshSnapshots(poolName)
|
|
||||||
} else if (action === "destroy") {
|
|
||||||
if (!confirm(`Destroy snapshot ${snap.name.split("@")[1]}?`)) return
|
|
||||||
await api.deleteSnapshot(snap.name)
|
|
||||||
refreshSnapshots(poolName)
|
|
||||||
} else if (action === "rename") {
|
|
||||||
setRenameValue(snap.name.split("@")[1])
|
|
||||||
setRenameTarget(snap)
|
|
||||||
} else if (action === "clone") {
|
|
||||||
setCloneValue(`${snap.dataset}-clone`)
|
|
||||||
setCloneTarget(snap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRenameConfirm = async () => {
|
|
||||||
if (!renameTarget || !renameValue.trim()) return
|
|
||||||
await api.renameSnapshot(renameTarget.name, renameValue.trim())
|
|
||||||
refreshSnapshots(renameTarget.name.split("@")[0].split("/")[0])
|
|
||||||
setRenameTarget(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloneConfirm = async () => {
|
|
||||||
if (!cloneTarget || !cloneValue.trim()) return
|
|
||||||
await api.cloneSnapshot(cloneTarget.name, cloneValue.trim())
|
|
||||||
setCloneTarget(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDiskMenuOpen = (e: React.MouseEvent, disk: string, poolName: string) => {
|
|
||||||
setDiskMenu({ disk, poolName, x: e.clientX, y: e.clientY })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDiskAction = async (action: string) => {
|
|
||||||
if (!diskMenu) return
|
|
||||||
const { disk, poolName } = diskMenu
|
|
||||||
setDiskMenu(null)
|
|
||||||
if (action === "offline") {
|
|
||||||
await api.diskOffline(poolName, disk)
|
|
||||||
refreshPoolStatus(poolName)
|
|
||||||
} else if (action === "online") {
|
|
||||||
await api.diskOnline(poolName, disk)
|
|
||||||
refreshPoolStatus(poolName)
|
|
||||||
} else if (action === "detach") {
|
|
||||||
if (!confirm(`Detach ${disk} from ${poolName}?`)) return
|
|
||||||
await api.diskDetach(poolName, disk)
|
|
||||||
refreshPoolStatus(poolName)
|
|
||||||
loadPools()
|
|
||||||
} else if (action === "clear") {
|
|
||||||
await api.clearDiskErrors(poolName, disk)
|
|
||||||
refreshPoolStatus(poolName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePoolAction = async (action: string, pool: Pool) => {
|
|
||||||
setPoolMenu(null)
|
|
||||||
if (action === "scrub") {
|
|
||||||
await api.startScrub(pool.name)
|
|
||||||
refreshPoolStatus(pool.name)
|
|
||||||
} else if (action === "clear") {
|
|
||||||
await api.clearPoolErrors(pool.name)
|
|
||||||
refreshPoolStatus(pool.name)
|
|
||||||
} else if (action === "resilver") {
|
|
||||||
await api.resilverPool(pool.name)
|
|
||||||
refreshPoolStatus(pool.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateFilesystem = async () => {
|
|
||||||
if (!newFsName.trim()) return
|
|
||||||
const fullName = createFsPool ? `${createFsPool}/${newFsName.trim()}` : newFsName.trim()
|
|
||||||
try {
|
|
||||||
await api.createDataset(fullName, {})
|
|
||||||
setNewFsName("")
|
|
||||||
setShowCreateFilesystem(false)
|
|
||||||
const cur = poolStates.get(createFsPool)
|
|
||||||
if (cur?.expanded) {
|
|
||||||
const ds = await api.getDatasets(createFsPool)
|
|
||||||
updatePoolState(createFsPool, { datasets: ds })
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to create filesystem:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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">
|
|
||||||
|
|
||||||
{/* Page Header */}
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
||||||
<HardDrive className="w-6 h-6" />
|
|
||||||
Storage Pools
|
|
||||||
</h1>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => { setCreateFsPool(""); setNewFsName(""); setShowCreateFilesystem(true) }}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
|
||||||
Create Storage Pool
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={loadPools} disabled={loading}>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
|
||||||
<span className="ml-1 hidden sm:inline">Refresh</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{lastRefresh && (
|
|
||||||
<p className="text-xs text-muted-foreground mb-4">
|
|
||||||
{lastRefresh.toLocaleString()} · {pools.length} pool{pools.length !== 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pools Table */}
|
|
||||||
<div className="border border-border rounded-lg overflow-hidden">
|
|
||||||
<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 w-8" />
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Name</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Health</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Size</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Allocated</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Free</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Fragmentation</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Usage</th>
|
|
||||||
<th className="px-4 py-3 text-right font-medium w-10" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{loading && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={9} className="px-4 py-8 text-center text-muted-foreground">
|
|
||||||
<RefreshCw className="w-4 h-4 animate-spin inline mr-2" />
|
|
||||||
Loading…
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!loading && pools.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={9} className="px-4 py-8 text-center text-muted-foreground">
|
|
||||||
No storage pools found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{pools.map((pool) => {
|
|
||||||
const state = poolStates.get(pool.name)
|
|
||||||
const isExpanded = state?.expanded ?? false
|
|
||||||
const currentTab = state?.tab ?? "filesystems"
|
|
||||||
|
|
||||||
return [
|
|
||||||
/* Pool row */
|
|
||||||
<tr
|
|
||||||
key={pool.name}
|
|
||||||
className="border-b border-border hover:bg-muted/30 cursor-pointer select-none"
|
|
||||||
onClick={() => togglePool(pool)}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground">
|
|
||||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 font-medium font-mono">{pool.name}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<HealthBadge health={pool.health} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-xs">{formatBytes(pool.size)}</td>
|
|
||||||
<td className="px-4 py-3 text-xs">{formatBytes(pool.alloc)}</td>
|
|
||||||
<td className="px-4 py-3 text-xs">{formatBytes(pool.free)}</td>
|
|
||||||
<td className="px-4 py-3 text-xs">{pool.fragmentation}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<UsageBar alloc={pool.alloc} size={pool.size} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
className="p-1 rounded hover:bg-muted"
|
|
||||||
onClick={(e) => setPoolMenu({ pool, x: e.clientX, y: e.clientY })}
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>,
|
|
||||||
|
|
||||||
/* Expanded panel */
|
|
||||||
isExpanded && (
|
|
||||||
<tr key={`${pool.name}-panel`}>
|
|
||||||
<td colSpan={9} className="border-b border-border bg-muted/10 p-0">
|
|
||||||
<div className="px-6 pt-2 pb-4">
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-0 border-b border-border mb-4">
|
|
||||||
{(["filesystems", "snapshots", "status"] as PoolTab[]).map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => switchTab(pool.name, t)}
|
|
||||||
className={`px-4 py-2 text-sm font-medium capitalize transition-colors border-b-2 -mb-px ${
|
|
||||||
currentTab === t
|
|
||||||
? "border-primary text-primary"
|
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t === "filesystems" ? "File Systems" : t.charAt(0).toUpperCase() + t.slice(1)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Tab-level actions */}
|
|
||||||
<div className="ml-auto flex items-center gap-2 pb-1">
|
|
||||||
{currentTab === "filesystems" && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="default"
|
|
||||||
onClick={() => { setCreateFsPool(pool.name); setNewFsName(""); setShowCreateFilesystem(true) }}
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
|
||||||
Create Filesystem
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={async () => {
|
|
||||||
updatePoolState(pool.name, { loadingDatasets: true })
|
|
||||||
const ds = await api.getDatasets(pool.name)
|
|
||||||
updatePoolState(pool.name, { datasets: ds, loadingDatasets: false })
|
|
||||||
}}>
|
|
||||||
<RefreshCw className={`w-3 h-3 ${state?.loadingDatasets ? "animate-spin" : ""}`} />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{currentTab === "snapshots" && (
|
|
||||||
<>
|
|
||||||
<Button size="sm" variant="default" onClick={async () => {
|
|
||||||
await api.createSnapshot(pool.name)
|
|
||||||
refreshSnapshots(pool.name)
|
|
||||||
}}>
|
|
||||||
<Camera className="w-3 h-3 mr-1" />
|
|
||||||
Create Snapshot
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => refreshSnapshots(pool.name)}>
|
|
||||||
<RefreshCw className={`w-3 h-3 ${state?.loadingSnapshots ? "animate-spin" : ""}`} />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{currentTab === "status" && (
|
|
||||||
<Button size="sm" variant="outline" onClick={() => refreshPoolStatus(pool.name)}>
|
|
||||||
<RefreshCw className={`w-3 h-3 mr-1 ${state?.loadingStatus ? "animate-spin" : ""}`} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── File Systems Tab ── */}
|
|
||||||
{currentTab === "filesystems" && (
|
|
||||||
state?.loadingDatasets ? (
|
|
||||||
<div className="py-6 text-center text-muted-foreground text-sm">
|
|
||||||
<RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead className="bg-muted/50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Name</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Available</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Used</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Snapshots</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Refreservation</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Record Size</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Compression</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Deduplication</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Share</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Mounted</th>
|
|
||||||
<th className="px-3 py-2 text-right font-medium w-8" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{(state?.datasets ?? []).map((ds) => {
|
|
||||||
const depth = ds.name.split("/").length - 1
|
|
||||||
return (
|
|
||||||
<tr key={ds.name} className="border-b border-border/40 hover:bg-muted/30">
|
|
||||||
<td className="px-3 py-2 font-mono" style={{ paddingLeft: `${depth * 16 + 12}px` }}>
|
|
||||||
{ds.name.split("/").pop()}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">{formatBytes(ds.avail)}</td>
|
|
||||||
<td className="px-3 py-2">{formatBytes(ds.used)}</td>
|
|
||||||
<td className="px-3 py-2 text-muted-foreground">—</td>
|
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
|
||||||
{ds.reservation ? formatBytes(ds.reservation) : "0 B"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-muted-foreground">—</td>
|
|
||||||
<td className="px-3 py-2">{ds.compression || "off"}</td>
|
|
||||||
<td className="px-3 py-2 text-muted-foreground">Off</td>
|
|
||||||
<td className="px-3 py-2 text-muted-foreground">Off</td>
|
|
||||||
<td className="px-3 py-2">{ds.mountpoint ? "Yes" : "No"}</td>
|
|
||||||
<td className="px-3 py-2 text-right">
|
|
||||||
<button className="p-1 rounded hover:bg-muted">
|
|
||||||
<MoreVertical className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{(state?.datasets ?? []).length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={11} className="px-3 py-4 text-center text-muted-foreground">
|
|
||||||
No filesystems
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Snapshots Tab ── */}
|
|
||||||
{currentTab === "snapshots" && (
|
|
||||||
state?.loadingSnapshots ? (
|
|
||||||
<div className="py-6 text-center text-muted-foreground text-sm">
|
|
||||||
<RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading…
|
|
||||||
</div>
|
|
||||||
) : (state?.snapshots ?? []).length === 0 ? (
|
|
||||||
<div className="py-6 text-center text-muted-foreground text-sm">No snapshots</div>
|
|
||||||
) : (() => {
|
|
||||||
const groups = new Map<string, Snapshot[]>()
|
|
||||||
;(state?.snapshots ?? []).forEach((s) => {
|
|
||||||
const ds = s.dataset || s.name.split("@")[0]
|
|
||||||
if (!groups.has(ds)) groups.set(ds, [])
|
|
||||||
groups.get(ds)!.push(s)
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead className="bg-muted/50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Name</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Created</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Used</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Referenced</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Clones</th>
|
|
||||||
<th className="px-3 py-2 text-right font-medium w-8" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{Array.from(groups.entries()).map(([ds, snaps]) => {
|
|
||||||
const groupExpanded = state?.expandedSnapGroups.has(ds) ?? false
|
|
||||||
return [
|
|
||||||
<tr
|
|
||||||
key={`grp-${ds}`}
|
|
||||||
className="border-b border-border bg-muted/20 hover:bg-muted/40 cursor-pointer"
|
|
||||||
onClick={() => toggleSnapGroup(pool.name, ds)}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2 font-mono font-medium" colSpan={5}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{groupExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
|
||||||
{ds}
|
|
||||||
<span className="text-muted-foreground font-normal">({snaps.length})</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td />
|
|
||||||
</tr>,
|
|
||||||
...(groupExpanded ? snaps.map((snap) => (
|
|
||||||
<tr key={snap.name} className="border-b border-border/40 hover:bg-muted/30">
|
|
||||||
<td className="px-3 py-1.5 font-mono pl-8">{snap.name.split("@")[1]}</td>
|
|
||||||
<td className="px-3 py-1.5 text-muted-foreground">
|
|
||||||
{snap.creation_datetime ? new Date(snap.creation_datetime).toLocaleString() : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-1.5">{formatBytes(snap.used)}</td>
|
|
||||||
<td className="px-3 py-1.5">{formatBytes(snap.referenced)}</td>
|
|
||||||
<td className="px-3 py-1.5 text-muted-foreground">—</td>
|
|
||||||
<td className="px-3 py-1.5 text-right">
|
|
||||||
<button
|
|
||||||
className="p-1 rounded hover:bg-muted"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setSnapContextMenu({ snap, x: e.clientX, y: e.clientY }) }}
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)) : [])
|
|
||||||
]
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Status Tab ── */}
|
|
||||||
{currentTab === "status" && (
|
|
||||||
state?.loadingStatus ? (
|
|
||||||
<div className="py-6 text-center text-muted-foreground text-sm">
|
|
||||||
<RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading…
|
|
||||||
</div>
|
|
||||||
) : state?.status ? (
|
|
||||||
<div>
|
|
||||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2 mb-6 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Pool: </span>
|
|
||||||
<span className="font-mono">{state.status.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">State: </span>
|
|
||||||
<HealthBadge health={state.status.state ?? state.status.health} />
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="font-medium">Scan: </span>
|
|
||||||
<span className="text-muted-foreground">{state.status.scan ?? "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="font-medium">Errors: </span>
|
|
||||||
<span className="text-muted-foreground">{state.status.errors ?? "No known data errors"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead className="bg-muted/50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-medium">Name</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium">State</th>
|
|
||||||
<th className="px-4 py-2 text-center font-medium">Read</th>
|
|
||||||
<th className="px-4 py-2 text-center font-medium">Write</th>
|
|
||||||
<th className="px-4 py-2 text-center font-medium">Checksum</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium">Temp</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium">SMART</th>
|
|
||||||
<th className="px-4 py-2 w-8" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<VdevTree
|
|
||||||
vdevs={state.status.vdevs ?? []}
|
|
||||||
poolName={pool.name}
|
|
||||||
onDiskMenu={handleDiskMenuOpen}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-6 text-center text-muted-foreground text-sm">
|
|
||||||
No status data available
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
),
|
|
||||||
]
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Filesystem Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={showCreateFilesystem}
|
|
||||||
onClose={() => setShowCreateFilesystem(false)}
|
|
||||||
title="Create Filesystem"
|
|
||||||
>
|
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
|
||||||
Pool: <span className="font-mono">{createFsPool || "—"}</span>
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={`name (will be created as ${createFsPool}/name)`}
|
|
||||||
value={newFsName}
|
|
||||||
onChange={(e) => setNewFsName(e.target.value)}
|
|
||||||
className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground font-mono text-sm"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFilesystem()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleCreateFilesystem} className="flex-1">Create</Button>
|
|
||||||
<Button onClick={() => setShowCreateFilesystem(false)} variant="outline" className="flex-1">Cancel</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Rename Snapshot Dialog */}
|
|
||||||
<Dialog open={!!renameTarget} onClose={() => setRenameTarget(null)} title="Rename Snapshot">
|
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
|
||||||
New name for <span className="font-mono">{renameTarget?.name.split("@")[1]}</span>
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={renameValue}
|
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
|
||||||
className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground font-mono"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleRenameConfirm()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleRenameConfirm} className="flex-1">Rename</Button>
|
|
||||||
<Button onClick={() => setRenameTarget(null)} variant="outline" className="flex-1">Cancel</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Clone Snapshot Dialog */}
|
|
||||||
<Dialog open={!!cloneTarget} onClose={() => setCloneTarget(null)} title="Clone Snapshot">
|
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
|
||||||
Clone <span className="font-mono">{cloneTarget?.name.split("@")[1]}</span> to:
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={cloneValue}
|
|
||||||
onChange={(e) => setCloneValue(e.target.value)}
|
|
||||||
placeholder="target dataset name"
|
|
||||||
className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground font-mono"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleCloneConfirm()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleCloneConfirm} className="flex-1">Clone</Button>
|
|
||||||
<Button onClick={() => setCloneTarget(null)} variant="outline" className="flex-1">Cancel</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Disk Context Menu */}
|
|
||||||
{diskMenu && (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setDiskMenu(null)} />
|
|
||||||
<div
|
|
||||||
className="fixed z-50 bg-background border border-border rounded-md shadow-lg py-1 min-w-[200px]"
|
|
||||||
style={{ top: diskMenu.y, left: diskMenu.x }}
|
|
||||||
>
|
|
||||||
<div className="px-4 py-1.5 text-xs text-muted-foreground font-mono border-b border-border mb-1">
|
|
||||||
{diskMenu.disk}
|
|
||||||
</div>
|
|
||||||
{[
|
|
||||||
{ action: "clear", label: "Clear Disk Errors" },
|
|
||||||
{ action: "offline", label: "Offline Disk" },
|
|
||||||
{ action: "online", label: "Online Disk" },
|
|
||||||
{ action: "detach", label: "Detach Disk", danger: true },
|
|
||||||
].map(({ action, label, danger }) => (
|
|
||||||
<button
|
|
||||||
key={action}
|
|
||||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-muted ${danger ? "text-destructive" : ""}`}
|
|
||||||
onClick={() => handleDiskAction(action)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pool Context Menu */}
|
|
||||||
{poolMenu && (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setPoolMenu(null)} />
|
|
||||||
<div
|
|
||||||
className="fixed z-50 bg-background border border-border rounded-md shadow-lg py-1 min-w-[220px]"
|
|
||||||
style={{ top: poolMenu.y, left: poolMenu.x }}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ action: "scrub", label: "Scrub Storage Pool" },
|
|
||||||
{ action: "resilver", label: "Resilver Storage Pool" },
|
|
||||||
{ action: "clear", label: "Clear Storage Pool Errors" },
|
|
||||||
].map(({ action, label }) => (
|
|
||||||
<button
|
|
||||||
key={action}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm hover:bg-muted"
|
|
||||||
onClick={() => handlePoolAction(action, poolMenu.pool)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Snapshot Context Menu */}
|
|
||||||
{snapContextMenu && (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setSnapContextMenu(null)} />
|
|
||||||
<div
|
|
||||||
className="fixed z-50 bg-background border border-border rounded-md shadow-lg py-1 min-w-[200px]"
|
|
||||||
style={{ top: snapContextMenu.y, left: snapContextMenu.x }}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ action: "clone", label: "Clone Snapshot" },
|
|
||||||
{ action: "rename", label: "Rename Snapshot" },
|
|
||||||
{ action: "rollback", label: "Roll Back Snapshot" },
|
|
||||||
{ action: "destroy", label: "Destroy Snapshot", danger: true },
|
|
||||||
].map(({ action, label, danger }) => (
|
|
||||||
<button
|
|
||||||
key={action}
|
|
||||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-muted ${danger ? "text-destructive" : ""}`}
|
|
||||||
onClick={() => {
|
|
||||||
const poolName = snapContextMenu.snap.name.split("@")[0].split("/")[0]
|
|
||||||
handleSnapAction(action, snapContextMenu.snap, poolName)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"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
|
|
||||||
|
|
||||||
const navLink = (href: string, label: string, mobile = false) => (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className={`${mobile ? "block " : ""}px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive(href)
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={mobile ? () => setIsMenuOpen(false) : undefined}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
|
|
||||||
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">
|
|
||||||
{navLink("/", "Dashboard")}
|
|
||||||
{zfsAvailable && navLink("/zfs", "ZFS")}
|
|
||||||
{navLink("/navigator", "Navigator")}
|
|
||||||
{navLink("/shares", "Shares")}
|
|
||||||
{navLink("/file-sharing", "File Sharing")}
|
|
||||||
{navLink("/identities", "Identities")}
|
|
||||||
{navLink("/logs", "Logs")}
|
|
||||||
{navLink("/services", "Services")}
|
|
||||||
</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">
|
|
||||||
{navLink("/", "Dashboard", true)}
|
|
||||||
{zfsAvailable && navLink("/zfs", "ZFS", true)}
|
|
||||||
{navLink("/navigator", "Navigator", true)}
|
|
||||||
{navLink("/shares", "Shares", true)}
|
|
||||||
{navLink("/file-sharing", "File Sharing", true)}
|
|
||||||
{navLink("/identities", "Identities", true)}
|
|
||||||
{navLink("/logs", "Logs", true)}
|
|
||||||
{navLink("/services", "Services", true)}
|
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,556 +0,0 @@
|
|||||||
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
|
|
||||||
created: number
|
|
||||||
used: number
|
|
||||||
referenced: number
|
|
||||||
creation_datetime?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearPoolErrors(poolName: string): Promise<{ status: string }> {
|
|
||||||
const response = await this.client.post(`/api/pools/${poolName}/clear`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async resilverPool(poolName: string): Promise<{ status: string }> {
|
|
||||||
const response = await this.client.post(`/api/pools/${poolName}/resilver`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async diskOffline(poolName: string, disk: string): Promise<{ status: string }> {
|
|
||||||
const response = await this.client.post(`/api/pools/${poolName}/disk/offline`, null, { params: { disk } })
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async diskOnline(poolName: string, disk: string): Promise<{ status: string }> {
|
|
||||||
const response = await this.client.post(`/api/pools/${poolName}/disk/online`, null, { params: { disk } })
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async diskDetach(poolName: string, disk: string): Promise<{ status: string }> {
|
|
||||||
const response = await this.client.post(`/api/pools/${poolName}/disk/detach`, null, { params: { disk } })
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearDiskErrors(poolName: string, disk: string): Promise<{ status: string }> {
|
|
||||||
const response = await this.client.post(`/api/pools/${poolName}/clear`, null, { params: { disk } })
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDiskSmart(disk: string): Promise<{
|
|
||||||
model?: string
|
|
||||||
serial?: string
|
|
||||||
protocol?: string
|
|
||||||
power_on_hours?: number
|
|
||||||
temperature?: number
|
|
||||||
passed?: boolean
|
|
||||||
reallocated_sectors?: number
|
|
||||||
pending_sectors?: number
|
|
||||||
uncorrectable?: number
|
|
||||||
}> {
|
|
||||||
const response = await this.client.get(`/api/pools/disks/${disk}/smart`)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
async cloneSnapshot(snapshot: string, target: string): Promise<{ status: string }> {
|
|
||||||
const response = await this.client.post("/api/snapshots/clone", { snapshot, target })
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async renameSnapshot(snapshot: string, new_name: string): Promise<{ status: string }> {
|
|
||||||
const response = await this.client.post("/api/snapshots/rename", { snapshot, new_name })
|
|
||||||
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 updateSambaShare(oldName: string, share: SambaShare): Promise<{ status: string }> {
|
|
||||||
const response = await this.client.put(`/api/shares/samba/${oldName}`, share)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteSambaShare(name: string): Promise<{ status: string }> {
|
|
||||||
const response = await this.client.delete(`/api/shares/samba/${name}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSambaConfig(): Promise<{ parameters: Array<{ key: string; value: string }> }> {
|
|
||||||
const response = await this.client.get("/api/shares/samba/config")
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async setSambaConfig(parameters: { [key: string]: string }): Promise<{ status: string }> {
|
|
||||||
const response = await this.client.put("/api/shares/samba/config", { parameters })
|
|
||||||
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 getDiskUsage(): Promise<{ filesystems: { filesystem: string; mountpoint: string; total: number; used: number; available: number; capacity: number }[] }> {
|
|
||||||
const response = await this.client.get("/api/system/disk-usage")
|
|
||||||
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()
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"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])
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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(" ")
|
|
||||||
}
|
|
||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
/// <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.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
const nextConfig = {
|
|
||||||
reactStrictMode: true,
|
|
||||||
|
|
||||||
// Static export for Raspberry Pi
|
|
||||||
output: 'export',
|
|
||||||
|
|
||||||
// Compression
|
|
||||||
compress: true,
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = nextConfig
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { NextConfig } from "next"
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
output: "export",
|
|
||||||
images: {
|
|
||||||
unoptimized: true,
|
|
||||||
},
|
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default nextConfig
|
|
||||||
Generated
-5834
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"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
Reference in New Issue
Block a user