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:
parent
87e21e243e
commit
84ddf75afe
355
src/pages/Proxmox/AdminPage.tsx
Normal file
355
src/pages/Proxmox/AdminPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/pages/Proxmox/NotesPage.tsx
Normal file
105
src/pages/Proxmox/NotesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/pages/Proxmox/SearchPage.tsx
Normal file
127
src/pages/Proxmox/SearchPage.tsx
Normal 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 “{query}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user