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:
parent
2d54858968
commit
b2da13fbbe
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<p className="text-muted-foreground">Network interfaces and bridges</p>
|
||||
</div>
|
||||
<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 className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Network Interfaces</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground">Network interface configuration coming soon</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{error && (
|
||||
<div className="rounded border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bridges</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground">Bridge configuration coming soon</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Network Interfaces</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">Tasks</h1>
|
||||
<p className="text-muted-foreground">Cluster task log and operations</p>
|
||||
</div>
|
||||
<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 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>
|
||||
|
||||
158
src/pages/Proxmox/ViewsPage.tsx
Normal file
158
src/pages/Proxmox/ViewsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user