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