feat(proxmox): implement network management, tasks, custom views, and connection health (phases 14-15)

- Replace NetworkPage placeholder with live network interface list (type, address, gateway, active/autostart badges)
- Replace TasksPage placeholder with real cluster task log including running/completed/failed summary cards
- Create ViewsPage with create/delete UI for custom dashboard views
- Fix createClusterView TS client to pass viewId + name params matching Rust command signature
- Fix ClusterView TS interface to use view_id matching Rust DashboardView serialization
- Add ClusterInfoWithHealth struct to list_proxmox_clusters command with connected field reflecting in-memory pool state
- Add connected? field to ClusterInfo domain type
- Wire /proxmox/views route and Views nav entry in App.tsx
This commit is contained in:
Shaun Arman 2026-06-12 21:58:31 -05:00
parent 2d54858968
commit b2da13fbbe
5 changed files with 431 additions and 60 deletions

View File

@ -14,6 +14,22 @@ pub struct ClusterConnection {
pub port: u16,
}
/// Cluster info enriched with live connection health status
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClusterInfoWithHealth {
pub id: String,
pub name: String,
pub cluster_type: ClusterType,
pub url: String,
pub port: u16,
pub username: String,
pub created_at: String,
pub updated_at: String,
/// True if an active client object exists in the in-memory connection pool
pub connected: bool,
}
/// Add a Proxmox cluster
#[tauri::command]
pub async fn add_proxmox_cluster(
@ -119,10 +135,12 @@ pub async fn remove_proxmox_cluster(id: String, state: State<'_, AppState>) -> R
Ok(())
}
/// List all Proxmox clusters
/// List all Proxmox clusters, annotated with live connection health
#[tauri::command]
pub async fn list_proxmox_clusters(state: State<'_, AppState>) -> Result<Vec<ClusterInfo>, String> {
let clusters = {
pub async fn list_proxmox_clusters(
state: State<'_, AppState>,
) -> Result<Vec<ClusterInfoWithHealth>, String> {
let db_clusters = {
let db = state
.db
.lock()
@ -154,11 +172,31 @@ pub async fn list_proxmox_clusters(state: State<'_, AppState>) -> Result<Vec<Clu
.map_err(|e| format!("Failed to query clusters: {}", e))?;
cluster_iter
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())
.collect::<Result<Vec<ClusterInfo>, _>>()
.map_err(|e| e.to_string())?
};
clusters
// Annotate each cluster with whether a live client exists in the connection pool
let live_clients = state.proxmox_clusters.lock().await;
let result = db_clusters
.into_iter()
.map(|c| {
let connected = live_clients.contains_key(&c.id);
ClusterInfoWithHealth {
id: c.id,
name: c.name,
cluster_type: c.cluster_type,
url: c.url,
port: c.port,
username: c.username,
created_at: c.created_at,
updated_at: c.updated_at,
connected,
}
})
.collect();
Ok(result)
}
/// Get a specific Proxmox cluster

View File

@ -877,10 +877,11 @@ export const listNetworkInterfaces = async (
// ─── Cluster Views (typed) ────────────────────────────────────────────────────
export interface ClusterView {
id: string;
view_id: string;
name: string;
includes?: string[];
excludes?: string[];
description?: string;
layout?: string;
enabled?: boolean;
}
/**
@ -895,13 +896,15 @@ export const listClusterViews = async (
/**
* Create a cluster view
* @param clusterId - Cluster identifier
* @param config - View configuration
* @param viewId - View identifier
* @param name - View display name
*/
export const createClusterView = async (
clusterId: string,
config: Partial<ClusterView>
viewId: string,
name: string
): Promise<void> =>
invoke<void>("create_cluster_view", { clusterId, config });
invoke<void>("create_cluster_view", { clusterId, viewId, name });
/**
* Delete a cluster view

View File

@ -1,43 +1,118 @@
import React from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { RefreshCw } from 'lucide-react';
import { Badge } from '@/components/ui/index';
import { RefreshCw, Network } from 'lucide-react';
import { listNetworkInterfaces, listProxmoxClusters, NetworkInterface } from '@/lib/proxmoxClient';
export function ProxmoxNetworkPage() {
const [interfaces, setInterfaces] = useState<NetworkInterface[]>([]);
const [clusterId, setClusterId] = useState('');
const [nodeId] = useState('localhost');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadInterfaces = useCallback(async (cId: string, nId: string) => {
if (!cId) return;
setLoading(true);
setError(null);
try {
const ifaces = await listNetworkInterfaces(cId, nId);
setInterfaces(ifaces);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
listProxmoxClusters()
.then((cls) => {
if (cls.length > 0) {
setClusterId(cls[0].id);
void loadInterfaces(cls[0].id, nodeId);
}
})
.catch(console.error);
}, [loadInterfaces, nodeId]);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Network</h1>
<p className="text-muted-foreground">Configure network interfaces and bridges</p>
<p className="text-muted-foreground">Network interfaces and bridges</p>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
<Button
variant="outline"
size="sm"
onClick={() => void loadInterfaces(clusterId, nodeId)}
disabled={loading || !clusterId}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{error && (
<div className="rounded border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<Card>
<CardHeader>
<CardTitle>Network Interfaces</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">Network interface configuration coming soon</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bridges</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">Bridge configuration coming soon</div>
</CardContent>
</Card>
{loading ? (
<div className="text-sm text-muted-foreground">Loading...</div>
) : interfaces.length === 0 ? (
<div className="text-sm text-muted-foreground">
{clusterId ? 'No network interfaces found.' : 'No cluster configured.'}
</div>
) : (
<div className="space-y-2">
{interfaces.map((iface, i) => (
<div key={`${iface.iface}-${i}`} className="flex items-center gap-3 rounded border p-3">
<Network className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono font-medium">{iface.iface}</span>
<Badge variant="outline">{iface.type}</Badge>
<Badge variant={iface.active ? 'default' : 'secondary'}>
{iface.active ? 'Active' : 'Inactive'}
</Badge>
{iface.autostart && (
<Badge variant="outline" className="text-xs">Autostart</Badge>
)}
</div>
{(iface.address || iface.gateway) && (
<div className="mt-1 text-xs text-muted-foreground">
{iface.address && (
<span>
{iface.address}
{iface.netmask ? `/${iface.netmask}` : ''}
</span>
)}
{iface.gateway && (
<span className="ml-2">gw {iface.gateway}</span>
)}
</div>
)}
{iface.comments && (
<div className="mt-1 text-xs italic text-muted-foreground">
{iface.comments}
</div>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,45 +1,142 @@
import React from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { Badge } from '@/components/ui/index';
import { RefreshCw } from 'lucide-react';
import { ClusterOperationsList } from '@/components/Proxmox';
import { listClusterTasks, listProxmoxClusters, ClusterTask } from '@/lib/proxmoxClient';
function taskBadgeVariant(exitstatus?: string): 'default' | 'destructive' | 'secondary' {
if (!exitstatus) return 'secondary';
return exitstatus === 'OK' ? 'default' : 'destructive';
}
function taskBadgeLabel(exitstatus?: string): string {
if (!exitstatus) return 'running';
return exitstatus;
}
function formatTimestamp(epoch: number): string {
if (!epoch) return '-';
return new Date(epoch * 1000).toLocaleString();
}
export function ProxmoxTasksPage() {
const [tasks, setTasks] = useState<ClusterTask[]>([]);
const [clusterId, setClusterId] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadTasks = useCallback(async (cId: string) => {
if (!cId) return;
setLoading(true);
setError(null);
try {
const t = await listClusterTasks(cId, 100);
setTasks(t);
} catch (e) {
setError(String(e));
console.error(e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
listProxmoxClusters()
.then((cls) => {
if (cls.length > 0) {
setClusterId(cls[0].id);
void loadTasks(cls[0].id);
}
})
.catch(console.error);
}, [loadTasks]);
const runningCount = tasks.filter((t) => !t.exitstatus).length;
const completedCount = tasks.filter((t) => t.exitstatus === 'OK').length;
const failedCount = tasks.filter(
(t) => t.exitstatus && t.exitstatus !== 'OK'
).length;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Tasks & Operations</h1>
<p className="text-muted-foreground">Monitor cluster operations and tasks</p>
<h1 className="text-2xl font-bold">Tasks</h1>
<p className="text-muted-foreground">Cluster task log and operations</p>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
<Button
variant="outline"
size="sm"
onClick={() => void loadTasks(clusterId)}
disabled={loading || !clusterId}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{error && (
<div className="rounded border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="grid grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle>Task Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">Task summary widget coming soon</div>
<CardContent className="pt-4">
<div className="text-2xl font-bold text-yellow-500">{runningCount}</div>
<div className="text-sm text-muted-foreground">Running</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="text-2xl font-bold text-green-500">{completedCount}</div>
<div className="text-sm text-muted-foreground">Completed</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="text-2xl font-bold text-red-500">{failedCount}</div>
<div className="text-sm text-muted-foreground">Failed</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Cluster Operations</CardTitle>
<CardTitle>Task Log</CardTitle>
</CardHeader>
<CardContent>
<ClusterOperationsList
operations={[]}
onRefresh={() => {}}
/>
{loading ? (
<div className="text-sm text-muted-foreground">Loading tasks...</div>
) : tasks.length === 0 ? (
<div className="text-sm text-muted-foreground">
{clusterId ? 'No tasks found.' : 'No cluster configured.'}
</div>
) : (
<div className="space-y-0">
{tasks.map((t, i) => (
<div
key={`${t.upid}-${i}`}
className="flex flex-wrap items-center gap-3 border-b py-2 text-sm last:border-0"
>
<Badge variant={taskBadgeVariant(t.exitstatus)}>
{taskBadgeLabel(t.exitstatus)}
</Badge>
<span className="font-medium">{t.type}</span>
<span className="text-muted-foreground">{t.node}</span>
<span className="text-xs text-muted-foreground">{t.user}</span>
<span className="text-xs text-muted-foreground">
{formatTimestamp(t.starttime)}
</span>
<span className="ml-auto max-w-xs truncate font-mono text-xs text-muted-foreground">
{t.upid}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>

View File

@ -0,0 +1,158 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { Plus, Trash2, Eye } from 'lucide-react';
import {
listClusterViews,
createClusterView,
deleteClusterView,
listProxmoxClusters,
ClusterView,
} from '@/lib/proxmoxClient';
export function ProxmoxViewsPage() {
const [views, setViews] = useState<ClusterView[]>([]);
const [clusterId, setClusterId] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [newViewName, setNewViewName] = useState('');
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const loadViews = useCallback(async (cId: string) => {
if (!cId) return;
setError(null);
try {
const v = await listClusterViews(cId);
setViews(v);
} catch (e) {
setError(String(e));
}
}, []);
useEffect(() => {
listProxmoxClusters()
.then((cls) => {
if (cls.length > 0) {
setClusterId(cls[0].id);
void loadViews(cls[0].id);
}
})
.catch(console.error);
}, [loadViews]);
const handleCreate = async () => {
const trimmed = newViewName.trim();
if (!trimmed || !clusterId) return;
setError(null);
try {
// Generate a simple ID from the name (lowercase, hyphenated)
const viewId = trimmed.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
await createClusterView(clusterId, viewId, trimmed);
setNewViewName('');
setShowCreate(false);
void loadViews(clusterId);
} catch (e) {
setError(String(e));
}
};
const handleDelete = async (viewId: string) => {
if (!clusterId) return;
setDeleting(viewId);
setError(null);
try {
await deleteClusterView(clusterId, viewId);
void loadViews(clusterId);
} catch (e) {
setError(String(e));
} finally {
setDeleting(null);
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Views</h1>
<p className="text-muted-foreground">Custom resource views and dashboards</p>
</div>
<Button
onClick={() => setShowCreate(true)}
disabled={!clusterId || showCreate}
>
<Plus className="mr-2 h-4 w-4" />
New View
</Button>
</div>
{error && (
<div className="rounded border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{showCreate && (
<Card>
<CardHeader>
<CardTitle>Create View</CardTitle>
</CardHeader>
<CardContent className="flex gap-2">
<input
className="flex-1 rounded border bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="View name"
value={newViewName}
onChange={(e) => setNewViewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleCreate();
if (e.key === 'Escape') setShowCreate(false);
}}
autoFocus
/>
<Button onClick={() => void handleCreate()} disabled={!newViewName.trim()}>
Create
</Button>
<Button variant="outline" onClick={() => { setShowCreate(false); setNewViewName(''); }}>
Cancel
</Button>
</CardContent>
</Card>
)}
{views.length === 0 && !showCreate ? (
<Card>
<CardContent className="pt-4 text-sm text-muted-foreground">
{clusterId ? 'No custom views configured.' : 'No cluster configured.'}
</CardContent>
</Card>
) : (
<div className="space-y-2">
{views.map((v) => (
<Card key={v.view_id}>
<CardContent className="flex items-center justify-between pt-4">
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<span className="font-medium">{v.name}</span>
{v.description && (
<p className="text-xs text-muted-foreground">{v.description}</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void handleDelete(v.view_id)}
disabled={deleting === v.view_id}
title="Delete view"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}