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,
|
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
|
/// Add a Proxmox cluster
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_proxmox_cluster(
|
pub async fn add_proxmox_cluster(
|
||||||
@ -119,10 +135,12 @@ pub async fn remove_proxmox_cluster(id: String, state: State<'_, AppState>) -> R
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all Proxmox clusters
|
/// List all Proxmox clusters, annotated with live connection health
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_proxmox_clusters(state: State<'_, AppState>) -> Result<Vec<ClusterInfo>, String> {
|
pub async fn list_proxmox_clusters(
|
||||||
let clusters = {
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<ClusterInfoWithHealth>, String> {
|
||||||
|
let db_clusters = {
|
||||||
let db = state
|
let db = state
|
||||||
.db
|
.db
|
||||||
.lock()
|
.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))?;
|
.map_err(|e| format!("Failed to query clusters: {}", e))?;
|
||||||
|
|
||||||
cluster_iter
|
cluster_iter
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<ClusterInfo>, _>>()
|
||||||
.map_err(|e| e.to_string())
|
.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
|
/// Get a specific Proxmox cluster
|
||||||
|
|||||||
@ -877,10 +877,11 @@ export const listNetworkInterfaces = async (
|
|||||||
// ─── Cluster Views (typed) ────────────────────────────────────────────────────
|
// ─── Cluster Views (typed) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ClusterView {
|
export interface ClusterView {
|
||||||
id: string;
|
view_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
includes?: string[];
|
description?: string;
|
||||||
excludes?: string[];
|
layout?: string;
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -895,13 +896,15 @@ export const listClusterViews = async (
|
|||||||
/**
|
/**
|
||||||
* Create a cluster view
|
* Create a cluster view
|
||||||
* @param clusterId - Cluster identifier
|
* @param clusterId - Cluster identifier
|
||||||
* @param config - View configuration
|
* @param viewId - View identifier
|
||||||
|
* @param name - View display name
|
||||||
*/
|
*/
|
||||||
export const createClusterView = async (
|
export const createClusterView = async (
|
||||||
clusterId: string,
|
clusterId: string,
|
||||||
config: Partial<ClusterView>
|
viewId: string,
|
||||||
|
name: string
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
invoke<void>("create_cluster_view", { clusterId, config });
|
invoke<void>("create_cluster_view", { clusterId, viewId, name });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a cluster view
|
* 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
||||||
import { Button } 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() {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Network</h1>
|
<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>
|
||||||
<div className="flex space-x-2">
|
<Button
|
||||||
<Button variant="outline" size="sm">
|
variant="outline"
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
size="sm"
|
||||||
|
onClick={() => void loadInterfaces(clusterId, nodeId)}
|
||||||
|
disabled={loading || !clusterId}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Network Interfaces</CardTitle>
|
<CardTitle>Network Interfaces</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-sm text-muted-foreground">Network interface configuration coming soon</div>
|
{loading ? (
|
||||||
</CardContent>
|
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||||
</Card>
|
) : interfaces.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
<Card>
|
{clusterId ? 'No network interfaces found.' : 'No cluster configured.'}
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Bridges</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-sm text-muted-foreground">Bridge configuration coming soon</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</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>
|
</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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
||||||
import { Button } from '@/components/ui/index';
|
import { Button } from '@/components/ui/index';
|
||||||
|
import { Badge } from '@/components/ui/index';
|
||||||
import { RefreshCw } from 'lucide-react';
|
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() {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Tasks & Operations</h1>
|
<h1 className="text-2xl font-bold">Tasks</h1>
|
||||||
<p className="text-muted-foreground">Monitor cluster operations and tasks</p>
|
<p className="text-muted-foreground">Cluster task log and operations</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<Button
|
||||||
<Button variant="outline" size="sm">
|
variant="outline"
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
size="sm"
|
||||||
|
onClick={() => void loadTasks(clusterId)}
|
||||||
|
disabled={loading || !clusterId}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="pt-4">
|
||||||
<CardTitle>Task Summary</CardTitle>
|
<div className="text-2xl font-bold text-yellow-500">{runningCount}</div>
|
||||||
</CardHeader>
|
<div className="text-sm text-muted-foreground">Running</div>
|
||||||
<CardContent>
|
</CardContent>
|
||||||
<div className="text-sm text-muted-foreground">Task summary widget coming soon</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Cluster Operations</CardTitle>
|
<CardTitle>Task Log</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ClusterOperationsList
|
{loading ? (
|
||||||
operations={[]}
|
<div className="text-sm text-muted-foreground">Loading tasks...</div>
|
||||||
onRefresh={() => {}}
|
) : 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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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