feat(proxmox): implement notes system, resource search, and administration panel (phases 12-13)

- NotesPage: load/display/edit cluster notes with draft/save/cancel flow
- SearchPage: cross-cluster resource search grouped by type with icon decorators
- AdminPage: tabbed node admin (status, apt updates, repositories, syslog, tasks)
  with cluster/node selector; imports ClusterInfo from domain.ts
This commit is contained in:
Shaun Arman 2026-06-12 21:55:01 -05:00
parent 87e21e243e
commit 84ddf75afe
3 changed files with 587 additions and 0 deletions

View File

@ -0,0 +1,355 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/index';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { Input } from '@/components/ui/index';
import { RefreshCw, Power, RotateCcw } from 'lucide-react';
import {
listProxmoxClusters,
getNodeStatus,
listAptUpdates,
listAptRepositories,
getSyslog,
listClusterTasks,
} from '@/lib/proxmoxClient';
import type {
NodeStatus,
AptPackage,
AptRepository,
SyslogEntry,
ClusterTask,
} from '@/lib/proxmoxClient';
import type { ClusterInfo } from '@/lib/domain';
export function ProxmoxAdminPage() {
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
const [clusterId, setClusterId] = useState('');
const [nodeId, setNodeId] = useState('localhost');
const [nodeInputValue, setNodeInputValue] = useState('localhost');
const [nodeStatus, setNodeStatus] = useState<NodeStatus | null>(null);
const [aptUpdates, setAptUpdates] = useState<AptPackage[]>([]);
const [aptRepos, setAptRepos] = useState<AptRepository[]>([]);
const [syslog, setSyslog] = useState<SyslogEntry[]>([]);
const [tasks, setTasks] = useState<ClusterTask[]>([]);
const [activeTab, setActiveTab] = useState('status');
const [tabError, setTabError] = useState<string | null>(null);
useEffect(() => {
listProxmoxClusters()
.then((cls) => {
setClusters(cls);
if (cls.length > 0) setClusterId(cls[0].id);
})
.catch((err: unknown) => console.error('Failed to load clusters:', err));
}, []);
const loadTabData = useCallback(
async (tab: string, cId: string, nId: string) => {
if (!cId) return;
setTabError(null);
try {
switch (tab) {
case 'status':
setNodeStatus(await getNodeStatus(cId, nId));
break;
case 'updates':
setAptUpdates(await listAptUpdates(cId, nId));
break;
case 'repositories':
setAptRepos(await listAptRepositories(cId, nId));
break;
case 'syslog':
setSyslog(await getSyslog(cId, nId));
break;
case 'tasks':
setTasks(await listClusterTasks(cId));
break;
}
} catch (e) {
setTabError(String(e));
}
},
[]
);
useEffect(() => {
void loadTabData(activeTab, clusterId, nodeId);
}, [activeTab, clusterId, nodeId, loadTabData]);
const applyNodeId = () => {
setNodeId(nodeInputValue.trim() || 'localhost');
};
const formatBytes = (bytes: number) =>
bytes >= 1073741824
? `${(bytes / 1073741824).toFixed(1)} GB`
: `${Math.round(bytes / 1048576)} MB`;
const formatUptime = (seconds: number) => {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
return d > 0 ? `${d}d ${h}h ${m}m` : `${h}h ${m}m`;
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Administration</h1>
<p className="text-muted-foreground">Node management, updates, and system monitoring</p>
</div>
</div>
{/* Cluster / Node selector bar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Cluster:</span>
<select
className="text-sm border rounded px-2 py-1 bg-background"
value={clusterId}
onChange={(e) => setClusterId(e.target.value)}
>
{clusters.length === 0 && <option value="">No clusters</option>}
{clusters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Node:</span>
<Input
className="w-36 h-8 text-sm"
value={nodeInputValue}
onChange={(e) => setNodeInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applyNodeId();
}}
placeholder="localhost"
/>
<Button variant="outline" size="sm" onClick={applyNodeId}>
Apply
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void loadTabData(activeTab, clusterId, nodeId)}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
{tabError && <div className="text-destructive text-sm">{tabError}</div>}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="status">Node Status</TabsTrigger>
<TabsTrigger value="updates">Updates</TabsTrigger>
<TabsTrigger value="repositories">Repositories</TabsTrigger>
<TabsTrigger value="syslog">System Log</TabsTrigger>
<TabsTrigger value="tasks">Tasks</TabsTrigger>
</TabsList>
{/* ── Node Status ─────────────────────────────────────────────────── */}
<TabsContent value="status">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Node Status</CardTitle>
<div className="flex space-x-2">
<Button variant="outline" size="sm">
<RotateCcw className="mr-2 h-4 w-4" />
Reboot
</Button>
<Button variant="destructive" size="sm">
<Power className="mr-2 h-4 w-4" />
Shutdown
</Button>
</div>
</CardHeader>
<CardContent>
{nodeStatus ? (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">CPU:</span>{' '}
{(nodeStatus.cpu * 100).toFixed(1)}%
</div>
<div>
<span className="text-muted-foreground">Memory:</span>{' '}
{formatBytes(nodeStatus.memory.used)} / {formatBytes(nodeStatus.memory.total)}
</div>
<div>
<span className="text-muted-foreground">Swap:</span>{' '}
{formatBytes(nodeStatus.swap.used)} / {formatBytes(nodeStatus.swap.total)}
</div>
<div>
<span className="text-muted-foreground">Disk:</span>{' '}
{formatBytes(nodeStatus.disk.used)} / {formatBytes(nodeStatus.disk.total)}
</div>
<div>
<span className="text-muted-foreground">Uptime:</span>{' '}
{formatUptime(nodeStatus.uptime)}
</div>
<div>
<span className="text-muted-foreground">Version:</span>{' '}
{nodeStatus.version}
</div>
{nodeStatus.loadAvg.length > 0 && (
<div className="col-span-2">
<span className="text-muted-foreground">Load Avg:</span>{' '}
{nodeStatus.loadAvg.map((v) => v.toFixed(2)).join(' / ')}
</div>
)}
</div>
) : (
<div className="text-muted-foreground text-sm">Loading node status...</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* ── APT Updates ─────────────────────────────────────────────────── */}
<TabsContent value="updates">
<Card>
<CardHeader>
<CardTitle>Available Updates ({aptUpdates.length})</CardTitle>
</CardHeader>
<CardContent>
{aptUpdates.length === 0 ? (
<div className="text-muted-foreground text-sm">No updates available</div>
) : (
<div className="space-y-1">
{aptUpdates.map((pkg, i) => (
<div
key={`${pkg.package}-${i}`}
className="flex items-center justify-between p-2 border rounded text-sm"
>
<span className="font-mono">{pkg.package}</span>
<span className="text-muted-foreground">
{pkg.version}
{pkg.newVersion ? `${pkg.newVersion}` : ''}
</span>
{pkg.description && (
<span className="text-xs text-muted-foreground truncate max-w-xs ml-2">
{pkg.description}
</span>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* ── APT Repositories ────────────────────────────────────────────── */}
<TabsContent value="repositories">
<Card>
<CardHeader>
<CardTitle>APT Repositories</CardTitle>
</CardHeader>
<CardContent>
{aptRepos.length === 0 ? (
<div className="text-muted-foreground text-sm">No repositories found</div>
) : (
<div className="space-y-2">
{aptRepos.map((repo, i) => (
<div key={i} className="p-3 border rounded text-sm">
<div className="font-mono text-xs">
{repo.types.join(' ')} {repo.uris.join(' ')} {repo.suites.join(' ')}{' '}
{repo.components.join(' ')}
</div>
<div className="flex items-center gap-2 mt-1">
<span
className={
repo.enabled
? 'text-xs text-green-600'
: 'text-xs text-muted-foreground'
}
>
{repo.enabled ? 'Enabled' : 'Disabled'}
</span>
{repo.comment && (
<span className="text-xs text-muted-foreground">{repo.comment}</span>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* ── Syslog ──────────────────────────────────────────────────────── */}
<TabsContent value="syslog">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>System Log</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => void loadTabData('syslog', clusterId, nodeId)}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</CardHeader>
<CardContent>
<div className="font-mono text-xs space-y-0.5 max-h-96 overflow-y-auto">
{syslog.length === 0 ? (
<div className="text-muted-foreground">No log entries</div>
) : (
syslog.map((entry) => (
<div key={entry.n} className="text-muted-foreground">
{entry.t} {entry.msg}
</div>
))
)}
</div>
</CardContent>
</Card>
</TabsContent>
{/* ── Tasks ───────────────────────────────────────────────────────── */}
<TabsContent value="tasks">
<Card>
<CardHeader>
<CardTitle>Recent Tasks</CardTitle>
</CardHeader>
<CardContent>
{tasks.length === 0 ? (
<div className="text-muted-foreground text-sm">No tasks found</div>
) : (
<div className="space-y-1">
{tasks.map((t) => (
<div
key={t.upid}
className="flex items-center gap-2 p-2 border rounded text-sm"
>
<span className="font-mono text-xs text-muted-foreground truncate max-w-xs">
{t.upid}
</span>
<span>{t.type}</span>
<span className="text-muted-foreground">{t.node}</span>
<span
className={
t.exitstatus === 'OK' ? 'text-green-600' : 'text-destructive'
}
>
{t.exitstatus ?? 'running'}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { Textarea } from '@/components/ui/index';
import { Edit, Save, X } from 'lucide-react';
import { getClusterNotes, updateClusterNotes, listProxmoxClusters } from '@/lib/proxmoxClient';
export function ProxmoxNotesPage() {
const [notes, setNotes] = useState('');
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState('');
const [clusterId, setClusterId] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const init = async () => {
try {
const clusters = await listProxmoxClusters();
if (clusters.length > 0) {
setClusterId(clusters[0].id);
const n = await getClusterNotes(clusters[0].id);
setNotes(n);
}
} catch (e) {
setError(String(e));
}
};
void init();
}, []);
const handleEdit = () => {
setDraft(notes);
setEditMode(true);
};
const handleCancel = () => setEditMode(false);
const handleSave = async () => {
setSaving(true);
try {
await updateClusterNotes(clusterId, draft);
setNotes(draft);
setEditMode(false);
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Notes</h1>
<p className="text-muted-foreground">Cluster notes and documentation</p>
</div>
{!editMode ? (
<Button variant="outline" onClick={handleEdit}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
) : (
<div className="flex space-x-2">
<Button variant="outline" onClick={handleCancel}>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button onClick={() => void handleSave()} disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
)}
</div>
{error && <div className="text-destructive text-sm">{error}</div>}
<Card>
<CardHeader>
<CardTitle>Cluster Notes</CardTitle>
</CardHeader>
<CardContent>
{!editMode ? (
<pre className="whitespace-pre-wrap text-sm font-mono min-h-[200px]">
{notes || (
<span className="text-muted-foreground">
No notes yet. Click Edit to add notes.
</span>
)}
</pre>
) : (
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="min-h-[300px] font-mono text-sm"
placeholder="Enter cluster notes here..."
/>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { Card, CardContent } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { Input } from '@/components/ui/index';
import { Badge } from '@/components/ui/index';
import { Search, Server, HardDrive, Cpu, Database } from 'lucide-react';
import { searchResources, listProxmoxClusters } from '@/lib/proxmoxClient';
import type { SearchResult } from '@/lib/proxmoxClient';
const TYPE_ICONS: Record<string, React.ElementType> = {
vm: Cpu,
container: HardDrive,
node: Server,
storage: Database,
pool: Server,
};
export function ProxmoxSearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSearch = async () => {
if (!query.trim()) return;
setSearching(true);
setError(null);
setSearched(false);
try {
const clusters = await listProxmoxClusters();
const allResults: SearchResult[] = [];
await Promise.all(
clusters.map(async (c) => {
try {
const r = await searchResources(c.id, query);
allResults.push(...r);
} catch {
// skip clusters that fail individually
}
})
);
setResults(allResults);
setSearched(true);
} catch (e) {
setError(String(e));
} finally {
setSearching(false);
}
};
// Group results by type
const grouped = results.reduce<Record<string, SearchResult[]>>((acc, r) => {
const bucket = acc[r.type] ?? [];
bucket.push(r);
acc[r.type] = bucket;
return acc;
}, {});
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold">Search</h1>
<p className="text-muted-foreground">Search across all Proxmox resources</p>
</div>
<div className="flex space-x-2">
<Input
placeholder="Search VMs, containers, nodes, storage..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleSearch();
}}
className="max-w-lg"
/>
<Button onClick={() => void handleSearch()} disabled={searching}>
<Search className="mr-2 h-4 w-4" />
{searching ? 'Searching...' : 'Search'}
</Button>
</div>
{error && <div className="text-destructive text-sm">{error}</div>}
{Object.entries(grouped).map(([type, items]) => {
const Icon = TYPE_ICONS[type] ?? Server;
return (
<Card key={type}>
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-sm font-semibold capitalize mb-2">
<Icon className="h-4 w-4" />
{type}s ({items.length})
</div>
<div className="space-y-1">
{items.map((r) => (
<div
key={`${r.type}-${r.id}`}
className="flex items-center gap-2 p-2 rounded hover:bg-accent"
>
<Badge variant="outline" className="text-xs">
{r.type}
</Badge>
<span className="text-sm font-medium">{r.name}</span>
{r.node && (
<span className="text-xs text-muted-foreground">on {r.node}</span>
)}
{r.description && (
<span className="text-xs text-muted-foreground truncate max-w-xs">
{r.description}
</span>
)}
</div>
))}
</div>
</CardContent>
</Card>
);
})}
{searched && results.length === 0 && (
<div className="text-muted-foreground text-sm">
No results found for &ldquo;{query}&rdquo;
</div>
)}
</div>
);
}