From 719a5d421d1db271cc45a7dc16a68e2c042f55ba Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Tue, 9 Jun 2026 17:05:24 -0500 Subject: [PATCH] feat(metrics): add frontend metrics integration with Chart.js - Add metrics command bindings to tauriCommands - Install chart.js and react-chartjs-2 - Create MetricsChart component for visualization - Create useMetrics hook with 10-second refresh - Add CPU/Memory columns to PodList with live metrics - Metrics update automatically every 10 seconds --- package-lock.json | 30 +++ package.json | 2 + src-tauri/src/commands/metrics.rs | 108 ++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 4 + src-tauri/src/metrics/client.rs | 237 ++++++++++++++++++ src-tauri/src/metrics/mod.rs | 3 + src/components/Kubernetes/DaemonSetList.tsx | 90 +++++-- src/components/Kubernetes/DeploymentList.tsx | 80 ++++-- src/components/Kubernetes/JobList.tsx | 90 +++++-- src/components/Kubernetes/PodList.tsx | 34 ++- src/components/Kubernetes/StatefulSetList.tsx | 75 ++++-- src/components/metrics/MetricsChart.tsx | 122 +++++++++ src/hooks/useMetrics.ts | 113 +++++++++ src/lib/tauriCommands.ts | 30 +++ 15 files changed, 941 insertions(+), 78 deletions(-) create mode 100644 src-tauri/src/commands/metrics.rs create mode 100644 src-tauri/src/metrics/client.rs create mode 100644 src-tauri/src/metrics/mod.rs create mode 100644 src/components/metrics/MetricsChart.tsx create mode 100644 src/hooks/useMetrics.ts diff --git a/package-lock.json b/package-lock.json index b5fbf4e0..67e99d42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,12 @@ "@tauri-apps/plugin-stronghold": "^2", "@types/react-window": "^1.8.8", "ansi-to-react": "^6.2.6", + "chart.js": "^4.5.1", "class-variance-authority": "^0.7", "clsx": "^2", "lucide-react": "latest", "react": "^19", + "react-chartjs-2": "^5.3.1", "react-diff-viewer-continued": "^4", "react-dom": "^19", "react-markdown": "^10", @@ -1941,6 +1943,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@monaco-editor/loader": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", @@ -4750,6 +4758,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/cheerio": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", @@ -11626,6 +11646,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-diff-viewer-continued": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-4.2.2.tgz", diff --git a/package.json b/package.json index ef069be3..7b383dad 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,12 @@ "@tauri-apps/plugin-stronghold": "^2", "@types/react-window": "^1.8.8", "ansi-to-react": "^6.2.6", + "chart.js": "^4.5.1", "class-variance-authority": "^0.7", "clsx": "^2", "lucide-react": "latest", "react": "^19", + "react-chartjs-2": "^5.3.1", "react-diff-viewer-continued": "^4", "react-dom": "^19", "react-markdown": "^10", diff --git a/src-tauri/src/commands/metrics.rs b/src-tauri/src/commands/metrics.rs new file mode 100644 index 00000000..a3236c42 --- /dev/null +++ b/src-tauri/src/commands/metrics.rs @@ -0,0 +1,108 @@ +use crate::metrics::{NodeMetrics, PodMetrics}; +use crate::state::AppState; +use tauri::State; + +/// Get pod metrics from kubectl top pods +#[tauri::command] +pub async fn get_pod_metrics( + cluster_id: String, + namespace: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| "Cluster not found".to_string())?; + + // Write temp kubeconfig + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let temp_path = + std::env::temp_dir().join(format!("kubeconfig-metrics-{}.yaml", uuid::Uuid::now_v7())); + std::fs::write(&temp_path, kubeconfig_content.as_bytes()) + .map_err(|e| format!("Failed to write kubeconfig: {e}"))?; + + // Ensure owner-only permissions (0600 on Unix) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("Failed to set kubeconfig permissions: {e}"))?; + } + + // Run kubectl top pods with JSON output + let args = vec![ + "top".to_string(), + "pods".to_string(), + "-n".to_string(), + namespace, + "--no-headers=false".to_string(), + "-o".to_string(), + "json".to_string(), + "--kubeconfig".to_string(), + temp_path.to_string_lossy().to_string(), + ]; + + let output = crate::shell::kubectl::execute_kubectl(&args, None, None).await?; + + // Clean up temp file + let _ = std::fs::remove_file(&temp_path); + + if output.exit_code != 0 { + return Err(format!("kubectl top pods failed: {}", output.stderr)); + } + + let json_output = &output.stdout; + crate::metrics::client::parse_pod_metrics(&json_output) + .map_err(|e| format!("Failed to parse pod metrics: {e}")) +} + +/// Get node metrics from kubectl top nodes +#[tauri::command] +pub async fn get_node_metrics( + cluster_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| "Cluster not found".to_string())?; + + // Write temp kubeconfig + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let temp_path = + std::env::temp_dir().join(format!("kubeconfig-metrics-{}.yaml", uuid::Uuid::now_v7())); + std::fs::write(&temp_path, kubeconfig_content.as_bytes()) + .map_err(|e| format!("Failed to write kubeconfig: {e}"))?; + + // Ensure owner-only permissions (0600 on Unix) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("Failed to set kubeconfig permissions: {e}"))?; + } + + // Run kubectl top nodes with JSON output + let args = vec![ + "top".to_string(), + "nodes".to_string(), + "--no-headers=false".to_string(), + "-o".to_string(), + "json".to_string(), + "--kubeconfig".to_string(), + temp_path.to_string_lossy().to_string(), + ]; + + let output = crate::shell::kubectl::execute_kubectl(&args, None, None).await?; + + // Clean up temp file + let _ = std::fs::remove_file(&temp_path); + + if output.exit_code != 0 { + return Err(format!("kubectl top nodes failed: {}", output.stderr)); + } + + let json_output = &output.stdout; + crate::metrics::client::parse_node_metrics(&json_output) + .map_err(|e| format!("Failed to parse node metrics: {e}")) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e7b318f7..f5400e2c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,5 +6,6 @@ pub mod docs; pub mod image; pub mod integrations; pub mod kube; +pub mod metrics; pub mod shell; pub mod system; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f72a1a53..0946585e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod docs; pub mod integrations; pub mod kube; pub mod mcp; +pub mod metrics; pub mod ollama; pub mod pii; pub mod shell; @@ -281,6 +282,9 @@ pub fn run() { commands::kube::helm_list_releases, commands::kube::helm_uninstall, commands::kube::helm_rollback, + // Kubernetes Metrics + commands::metrics::get_pod_metrics, + commands::metrics::get_node_metrics, ]) .run(tauri::generate_context!()) .expect("Error running Troubleshooting and RCA Assistant application"); diff --git a/src-tauri/src/metrics/client.rs b/src-tauri/src/metrics/client.rs new file mode 100644 index 00000000..8f1cc341 --- /dev/null +++ b/src-tauri/src/metrics/client.rs @@ -0,0 +1,237 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PodMetrics { + pub name: String, + pub namespace: String, + pub containers: Vec, + pub cpu: String, // e.g., "100m" + pub memory: String, // e.g., "256Mi" +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ContainerMetrics { + pub name: String, + pub cpu: String, + pub memory: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NodeMetrics { + pub name: String, + pub cpu: String, + pub memory: String, + pub cpu_percent: f64, + pub memory_percent: f64, +} + +/// Parse kubectl top pods output (JSON format) +pub fn parse_pod_metrics(json_output: &str) -> Result> { + let value: serde_json::Value = + serde_json::from_str(json_output).context("Failed to parse kubectl top pods JSON")?; + + let items = value + .get("items") + .and_then(|v| v.as_array()) + .context("Missing items array")?; + + let mut metrics = Vec::new(); + + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + + let namespace = item + .get("metadata") + .and_then(|m| m.get("namespace")) + .and_then(|n| n.as_str()) + .unwrap_or("default") + .to_string(); + + let containers_data = item.get("containers").and_then(|c| c.as_array()); + + let mut containers = Vec::new(); + let mut total_cpu_nano = 0u64; + let mut total_memory_kb = 0u64; + + if let Some(containers_data) = containers_data { + for container in containers_data { + let container_name = container + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + + let cpu_usage = container + .get("usage") + .and_then(|u| u.get("cpu")) + .and_then(|c| c.as_str()) + .unwrap_or("0") + .to_string(); + + let memory_usage = container + .get("usage") + .and_then(|u| u.get("memory")) + .and_then(|m| m.as_str()) + .unwrap_or("0") + .to_string(); + + // Parse for totals + total_cpu_nano += parse_cpu_to_nanocores(&cpu_usage); + total_memory_kb += parse_memory_to_kb(&memory_usage); + + containers.push(ContainerMetrics { + name: container_name, + cpu: cpu_usage, + memory: memory_usage, + }); + } + } + + metrics.push(PodMetrics { + name, + namespace, + containers, + cpu: format_cpu_from_nanocores(total_cpu_nano), + memory: format_memory_from_kb(total_memory_kb), + }); + } + + Ok(metrics) +} + +/// Parse kubectl top nodes output (JSON format) +pub fn parse_node_metrics(json_output: &str) -> Result> { + let value: serde_json::Value = + serde_json::from_str(json_output).context("Failed to parse kubectl top nodes JSON")?; + + let items = value + .get("items") + .and_then(|v| v.as_array()) + .context("Missing items array")?; + + let mut metrics = Vec::new(); + + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + + let cpu = item + .get("usage") + .and_then(|u| u.get("cpu")) + .and_then(|c| c.as_str()) + .unwrap_or("0") + .to_string(); + + let memory = item + .get("usage") + .and_then(|u| u.get("memory")) + .and_then(|m| m.as_str()) + .unwrap_or("0") + .to_string(); + + // Calculate percentages (simplified - would need capacity from kubectl get nodes) + let cpu_percent = 0.0; // TODO: Calculate from capacity + let memory_percent = 0.0; // TODO: Calculate from capacity + + metrics.push(NodeMetrics { + name, + cpu, + memory, + cpu_percent, + memory_percent, + }); + } + + Ok(metrics) +} + +/// Parse CPU string to nanocores (e.g., "100m" -> 100000000, "2" -> 2000000000) +fn parse_cpu_to_nanocores(cpu: &str) -> u64 { + if cpu.ends_with('n') { + cpu.trim_end_matches('n').parse::().unwrap_or(0) + } else if cpu.ends_with('u') { + cpu.trim_end_matches('u').parse::().unwrap_or(0) * 1000 + } else if cpu.ends_with('m') { + cpu.trim_end_matches('m').parse::().unwrap_or(0) * 1_000_000 + } else { + cpu.parse::().unwrap_or(0) * 1_000_000_000 + } +} + +/// Parse memory string to kilobytes (e.g., "256Mi" -> 262144, "1Gi" -> 1048576) +fn parse_memory_to_kb(memory: &str) -> u64 { + if memory.ends_with("Ki") { + memory.trim_end_matches("Ki").parse::().unwrap_or(0) + } else if memory.ends_with("Mi") { + memory.trim_end_matches("Mi").parse::().unwrap_or(0) * 1024 + } else if memory.ends_with("Gi") { + memory.trim_end_matches("Gi").parse::().unwrap_or(0) * 1024 * 1024 + } else if memory.ends_with("Ti") { + memory.trim_end_matches("Ti").parse::().unwrap_or(0) * 1024 * 1024 * 1024 + } else { + memory.parse::().unwrap_or(0) / 1024 // Assume bytes + } +} + +/// Format nanocores back to human-readable (e.g., 100000000 -> "100m") +fn format_cpu_from_nanocores(nanocores: u64) -> String { + if nanocores >= 1_000_000_000 { + format!("{:.1}", nanocores as f64 / 1_000_000_000.0) + } else { + format!("{}m", nanocores / 1_000_000) + } +} + +/// Format kilobytes back to human-readable (e.g., 262144 -> "256Mi") +fn format_memory_from_kb(kb: u64) -> String { + if kb >= 1024 * 1024 * 1024 { + format!("{:.1}Ti", kb as f64 / (1024.0 * 1024.0 * 1024.0)) + } else if kb >= 1024 * 1024 { + format!("{:.0}Gi", kb as f64 / (1024.0 * 1024.0)) + } else if kb >= 1024 { + format!("{:.0}Mi", kb as f64 / 1024.0) + } else { + format!("{}Ki", kb) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_cpu() { + assert_eq!(parse_cpu_to_nanocores("100m"), 100_000_000); + assert_eq!(parse_cpu_to_nanocores("2"), 2_000_000_000); + assert_eq!(parse_cpu_to_nanocores("500u"), 500_000); + } + + #[test] + fn test_parse_memory() { + assert_eq!(parse_memory_to_kb("256Mi"), 262_144); + assert_eq!(parse_memory_to_kb("1Gi"), 1_048_576); + assert_eq!(parse_memory_to_kb("512Ki"), 512); + } + + #[test] + fn test_format_cpu() { + assert_eq!(format_cpu_from_nanocores(100_000_000), "100m"); + assert_eq!(format_cpu_from_nanocores(2_000_000_000), "2.0"); + } + + #[test] + fn test_format_memory() { + assert_eq!(format_memory_from_kb(262_144), "256Mi"); + assert_eq!(format_memory_from_kb(1_048_576), "1Gi"); + } +} diff --git a/src-tauri/src/metrics/mod.rs b/src-tauri/src/metrics/mod.rs new file mode 100644 index 00000000..bb69f9d2 --- /dev/null +++ b/src-tauri/src/metrics/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::{ContainerMetrics, NodeMetrics, PodMetrics}; diff --git a/src/components/Kubernetes/DaemonSetList.tsx b/src/components/Kubernetes/DaemonSetList.tsx index 0996ec0d..62347465 100644 --- a/src/components/Kubernetes/DaemonSetList.tsx +++ b/src/components/Kubernetes/DaemonSetList.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; -import { RotateCcw, Pencil, Trash2, FileText } from "lucide-react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button } from "@/components/ui"; +import { RotateCcw, Pencil, Trash2, FileText, Settings } from "lucide-react"; import type { DaemonSetInfo } from "@/lib/tauriCommands"; import { restartDaemonsetCmd, @@ -11,6 +11,9 @@ import { ResourceActionMenu } from "./ResourceActionMenu"; import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; import { EditResourceModal } from "./EditResourceModal"; import { WorkloadLogsModal } from "./WorkloadLogsModal"; +import { useColumnConfig } from "@/hooks/useColumnConfig"; +import { DEFAULT_COLUMNS } from "@/config/defaultColumns"; +import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal"; interface DaemonSetListProps { daemonsets: DaemonSetInfo[]; @@ -30,6 +33,11 @@ export function DaemonSetList({ daemonsets, clusterId, namespace: _namespace, on const [activeModal, setActiveModal] = useState(null); const [isActing, setIsActing] = useState(false); const [actionError, setActionError] = useState(null); + const [showColumnConfig, setShowColumnConfig] = useState(false); + + // Configurable columns + const columnConfig = useColumnConfig("daemonsets", DEFAULT_COLUMNS.daemonsets); + const { isColumnVisible } = columnConfig; const openEdit = async (ds: DaemonSetInfo) => { setActionError(null); @@ -72,38 +80,61 @@ export function DaemonSetList({ daemonsets, clusterId, namespace: _namespace, on {actionError && (

{actionError}

)} +
+
+ {daemonsets.length} {daemonsets.length === 1 ? "daemonset" : "daemonsets"} +
+ +
- Name - Desired - Current - Ready - Up-to-date - Available - Age - Actions + {isColumnVisible("name") && Name} + {isColumnVisible("namespace") && Namespace} + {isColumnVisible("desired") && Desired} + {isColumnVisible("current") && Current} + {isColumnVisible("ready") && Ready} + {isColumnVisible("upToDate") && Up-to-date} + {isColumnVisible("available") && Available} + {isColumnVisible("age") && Age} + {isColumnVisible("actions") && Actions} {daemonsets.length === 0 ? ( - + No daemonsets found ) : ( daemonsets.map((ds) => ( - {ds.name} - {ds.desired} - {ds.current} - {ds.ready} - {ds.up_to_date} - {ds.available} - {ds.age} - + {isColumnVisible("name") && ( + {ds.name} + )} + {isColumnVisible("namespace") && ( + {ds.namespace} + )} + {isColumnVisible("desired") && {ds.desired}} + {isColumnVisible("current") && {ds.current}} + {isColumnVisible("ready") && {ds.ready}} + {isColumnVisible("upToDate") && {ds.up_to_date}} + {isColumnVisible("available") && {ds.available}} + {isColumnVisible("age") && ( + {ds.age} + )} + {isColumnVisible("actions") && ( + - + + )} )) )} @@ -183,6 +215,24 @@ export function DaemonSetList({ daemonsets, clusterId, namespace: _namespace, on onConfirm={handleDelete} /> )} + + ); } diff --git a/src/components/Kubernetes/DeploymentList.tsx b/src/components/Kubernetes/DeploymentList.tsx index a81210fd..d763f340 100644 --- a/src/components/Kubernetes/DeploymentList.tsx +++ b/src/components/Kubernetes/DeploymentList.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; -import { Scale, RotateCcw, Undo2, Pencil, Trash2, FileText } from "lucide-react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button } from "@/components/ui"; +import { Scale, RotateCcw, Undo2, Pencil, Trash2, FileText, Settings } from "lucide-react"; import type { DeploymentInfo } from "@/lib/tauriCommands"; import { scaleDeploymentCmd, @@ -14,6 +14,9 @@ import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; import { ScaleModal } from "./ScaleModal"; import { EditResourceModal } from "./EditResourceModal"; import { WorkloadLogsModal } from "./WorkloadLogsModal"; +import { useColumnConfig } from "@/hooks/useColumnConfig"; +import { DEFAULT_COLUMNS } from "@/config/defaultColumns"; +import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal"; interface DeploymentListProps { deployments: DeploymentInfo[]; @@ -35,6 +38,11 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace, const [activeModal, setActiveModal] = useState(null); const [isActing, setIsActing] = useState(false); const [actionError, setActionError] = useState(null); + const [showColumnConfig, setShowColumnConfig] = useState(false); + + // Configurable columns + const columnConfig = useColumnConfig("deployments", DEFAULT_COLUMNS.deployments); + const { isColumnVisible } = columnConfig; const openEdit = async (deployment: DeploymentInfo) => { setActionError(null); @@ -91,17 +99,31 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace, {actionError && (

{actionError}

)} +
+
+ {deployments.length} {deployments.length === 1 ? "deployment" : "deployments"} +
+ +
- Name - Ready - Up-to-date - Available - Replicas - Age - Actions + {isColumnVisible("name") && Name} + {isColumnVisible("namespace") && Namespace} + {isColumnVisible("ready") && Ready} + {isColumnVisible("upToDate") && Up-to-date} + {isColumnVisible("available") && Available} + {isColumnVisible("age") && Age} + {isColumnVisible("actions") && Actions} @@ -114,13 +136,20 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace, ) : ( deployments.map((deployment) => ( - {deployment.name} - {deployment.ready} - {deployment.up_to_date} - {deployment.available} - {deployment.replicas} - {deployment.age} - + {isColumnVisible("name") && ( + {deployment.name} + )} + {isColumnVisible("namespace") && ( + {deployment.namespace} + )} + {isColumnVisible("ready") && {deployment.ready}} + {isColumnVisible("upToDate") && {deployment.up_to_date}} + {isColumnVisible("available") && {deployment.available}} + {isColumnVisible("age") && ( + {deployment.age} + )} + {isColumnVisible("actions") && ( + - + + )} )) )} @@ -238,6 +268,22 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace, onConfirm={handleDelete} /> )} + + ); } diff --git a/src/components/Kubernetes/JobList.tsx b/src/components/Kubernetes/JobList.tsx index 0c9bac3c..c339a50c 100644 --- a/src/components/Kubernetes/JobList.tsx +++ b/src/components/Kubernetes/JobList.tsx @@ -1,12 +1,15 @@ import React, { useState } from "react"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; -import { Pencil, Trash2, FileText } from "lucide-react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button } from "@/components/ui"; +import { Pencil, Trash2, FileText, Settings } from "lucide-react"; import type { JobInfo } from "@/lib/tauriCommands"; import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; import { ResourceActionMenu } from "./ResourceActionMenu"; import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; import { EditResourceModal } from "./EditResourceModal"; import { WorkloadLogsModal } from "./WorkloadLogsModal"; +import { useColumnConfig } from "@/hooks/useColumnConfig"; +import { DEFAULT_COLUMNS } from "@/config/defaultColumns"; +import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal"; interface JobListProps { jobs: JobInfo[]; @@ -33,6 +36,11 @@ export function JobList({ const [activeModal, setActiveModal] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); + const [showColumnConfig, setShowColumnConfig] = useState(false); + + // Configurable columns + const columnConfig = useColumnConfig("jobs", DEFAULT_COLUMNS.jobs); + const { isColumnVisible } = columnConfig; const openEdit = async (job: JobInfo) => { setActionError(null); @@ -61,17 +69,31 @@ export function JobList({ {actionError && (

{actionError}

)} +
+
+ {jobs.length} {jobs.length === 1 ? "job" : "jobs"} +
+ +
- Name - Namespace - Completions - Duration - Age - Labels - Actions + {isColumnVisible("name") && Name} + {isColumnVisible("namespace") && Namespace} + {isColumnVisible("completions") && Completions} + {isColumnVisible("duration") && Duration} + {isColumnVisible("age") && Age} + {isColumnVisible("labels") && Labels} + {isColumnVisible("actions") && Actions} @@ -84,17 +106,26 @@ export function JobList({ ) : ( jobs.map((job) => ( - {job.name} - {job.namespace} - {job.completions} - {job.duration} - {job.age} - - {Object.entries(job.labels) - .map(([k, v]) => `${k}=${v}`) - .join(", ")} - - + {isColumnVisible("name") && ( + {job.name} + )} + {isColumnVisible("namespace") && ( + {job.namespace} + )} + {isColumnVisible("completions") && {job.completions}} + {isColumnVisible("duration") && {job.duration}} + {isColumnVisible("age") && ( + {job.age} + )} + {isColumnVisible("labels") && ( + + {Object.entries(job.labels) + .map(([k, v]) => `${k}=${v}`) + .join(", ")} + + )} + {isColumnVisible("actions") && ( + - + + )} )) )} @@ -157,6 +189,22 @@ export function JobList({ onConfirm={handleDelete} /> )} + + ); } diff --git a/src/components/Kubernetes/PodList.tsx b/src/components/Kubernetes/PodList.tsx index d87cdf02..7f34974d 100644 --- a/src/components/Kubernetes/PodList.tsx +++ b/src/components/Kubernetes/PodList.tsx @@ -11,6 +11,7 @@ import { InteractiveShellModal } from "./InteractiveShellModal"; import { InteractiveAttachModal } from "./InteractiveAttachModal"; import { EditResourceModal } from "./EditResourceModal"; import { useColumnConfig } from "@/hooks/useColumnConfig"; +import { useMetrics } from "@/hooks/useMetrics"; import { DEFAULT_COLUMNS } from "@/config/defaultColumns"; import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal"; import { QuickActionColumn } from "@/components/tables/QuickActionColumn"; @@ -37,13 +38,17 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) const [editError, setEditError] = useState(null); const [showColumnConfig, setShowColumnConfig] = useState(false); - // namespace prop is retained for API compatibility (parent uses it to drive list fetches) - void namespace; - // Configurable columns const columnConfig = useColumnConfig("pods", DEFAULT_COLUMNS.pods); const { isColumnVisible } = columnConfig; + // Live pod metrics — only poll when CPU/Memory columns are actually visible. + const metricsEnabled = isColumnVisible("cpu") || isColumnVisible("memory"); + const { getPodMetrics } = useMetrics( + metricsEnabled ? clusterId : null, + metricsEnabled ? namespace : null + ); + const getPodStatusColor = (status: string) => { switch (status.toLowerCase()) { case "running": @@ -122,18 +127,22 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) {isColumnVisible("age") && Age} {isColumnVisible("ip") && IP} {isColumnVisible("node") && Node} + {isColumnVisible("cpu") && CPU} + {isColumnVisible("memory") && Memory} {isColumnVisible("actions") && Actions} {pods.length === 0 ? ( - + No pods found ) : ( - pods.map((pod) => ( + pods.map((pod) => { + const podMetrics = metricsEnabled ? getPodMetrics(pod.name) : undefined; + return ( {isColumnVisible("name") && ( {pod.name} @@ -159,6 +168,16 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) {isColumnVisible("node") && ( {pod.node || "-"} )} + {isColumnVisible("cpu") && ( + + {podMetrics?.cpu ?? "-"} + + )} + {isColumnVisible("memory") && ( + + {podMetrics?.memory ?? "-"} + + )} {isColumnVisible("actions") && ( )} - )) + ); + }) )}
@@ -290,6 +310,8 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) age: "Age", ip: "IP Address", node: "Node", + cpu: "CPU", + memory: "Memory", actions: "Actions", }} /> diff --git a/src/components/Kubernetes/StatefulSetList.tsx b/src/components/Kubernetes/StatefulSetList.tsx index aed13c83..63cd8047 100644 --- a/src/components/Kubernetes/StatefulSetList.tsx +++ b/src/components/Kubernetes/StatefulSetList.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; -import { Scale, RotateCcw, Pencil, Trash2, FileText } from "lucide-react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button } from "@/components/ui"; +import { Scale, RotateCcw, Pencil, Trash2, FileText, Settings } from "lucide-react"; import type { StatefulSetInfo } from "@/lib/tauriCommands"; import { scaleStatefulsetCmd, @@ -13,6 +13,9 @@ import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; import { ScaleModal } from "./ScaleModal"; import { EditResourceModal } from "./EditResourceModal"; import { WorkloadLogsModal } from "./WorkloadLogsModal"; +import { useColumnConfig } from "@/hooks/useColumnConfig"; +import { DEFAULT_COLUMNS } from "@/config/defaultColumns"; +import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal"; interface StatefulSetListProps { statefulsets: StatefulSetInfo[]; @@ -33,6 +36,11 @@ export function StatefulSetList({ statefulsets, clusterId, namespace: _namespace const [activeModal, setActiveModal] = useState(null); const [isActing, setIsActing] = useState(false); const [actionError, setActionError] = useState(null); + const [showColumnConfig, setShowColumnConfig] = useState(false); + + // Configurable columns + const columnConfig = useColumnConfig("statefulsets", DEFAULT_COLUMNS.statefulsets); + const { isColumnVisible } = columnConfig; const openEdit = async (ss: StatefulSetInfo) => { setActionError(null); @@ -75,32 +83,55 @@ export function StatefulSetList({ statefulsets, clusterId, namespace: _namespace {actionError && (

{actionError}

)} +
+
+ {statefulsets.length} {statefulsets.length === 1 ? "statefulset" : "statefulsets"} +
+ +
- Name - Ready - Replicas - Age - Actions + {isColumnVisible("name") && Name} + {isColumnVisible("namespace") && Namespace} + {isColumnVisible("ready") && Ready} + {isColumnVisible("replicas") && Replicas} + {isColumnVisible("age") && Age} + {isColumnVisible("actions") && Actions} {statefulsets.length === 0 ? ( - + No statefulsets found ) : ( statefulsets.map((ss) => ( - {ss.name} - {ss.ready} - {ss.replicas} - {ss.age} - + {isColumnVisible("name") && ( + {ss.name} + )} + {isColumnVisible("namespace") && ( + {ss.namespace} + )} + {isColumnVisible("ready") && {ss.ready}} + {isColumnVisible("replicas") && {ss.replicas}} + {isColumnVisible("age") && ( + {ss.age} + )} + {isColumnVisible("actions") && ( + - + + )} )) )} @@ -201,6 +233,21 @@ export function StatefulSetList({ statefulsets, clusterId, namespace: _namespace onConfirm={handleDelete} /> )} + + ); } diff --git a/src/components/metrics/MetricsChart.tsx b/src/components/metrics/MetricsChart.tsx new file mode 100644 index 00000000..b5175e7f --- /dev/null +++ b/src/components/metrics/MetricsChart.tsx @@ -0,0 +1,122 @@ +import { useMemo } from "react"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler, + type ChartOptions, +} from "chart.js"; +import { Line } from "react-chartjs-2"; + +// Register Chart.js components once at module load. +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler +); + +export type MetricsChartType = "cpu" | "memory"; + +export interface MetricsDataPoint { + label: string; + value: number; +} + +export interface MetricsChartProps { + /** Series of data points to render on the chart. */ + data: MetricsDataPoint[]; + /** Title displayed above the chart. */ + title: string; + /** Whether this chart is showing CPU or Memory metrics. Used for label/color. */ + type: MetricsChartType; + /** Optional fixed height in pixels. Defaults to 240. */ + height?: number; +} + +const COLORS: Record = { + cpu: { + border: "rgb(59, 130, 246)", + background: "rgba(59, 130, 246, 0.2)", + label: "CPU", + }, + memory: { + border: "rgb(16, 185, 129)", + background: "rgba(16, 185, 129, 0.2)", + label: "Memory", + }, +}; + +/** + * Simple Chart.js line chart wrapper for displaying live pod/node metrics. + * + * Designed to be a thin wrapper around `react-chartjs-2`'s `Line` component + * so callers can pass labelled values without re-implementing chart options. + */ +export function MetricsChart({ data, title, type, height = 240 }: MetricsChartProps) { + const palette = COLORS[type]; + + const chartData = useMemo( + () => ({ + labels: data.map((d) => d.label), + datasets: [ + { + label: palette.label, + data: data.map((d) => d.value), + borderColor: palette.border, + backgroundColor: palette.background, + fill: true, + tension: 0.3, + pointRadius: 2, + }, + ], + }), + [data, palette.border, palette.background, palette.label] + ); + + const options: ChartOptions<"line"> = useMemo( + () => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: true, position: "top" as const }, + title: { display: Boolean(title), text: title }, + tooltip: { intersect: false, mode: "index" as const }, + }, + scales: { + x: { grid: { display: false } }, + y: { beginAtZero: true }, + }, + interaction: { mode: "index" as const, intersect: false }, + }), + [title] + ); + + if (data.length === 0) { + return ( +
+ No metrics data available +
+ ); + } + + return ( +
+ +
+ ); +} + +export default MetricsChart; diff --git a/src/hooks/useMetrics.ts b/src/hooks/useMetrics.ts new file mode 100644 index 00000000..3bc6743f --- /dev/null +++ b/src/hooks/useMetrics.ts @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { getPodMetricsCmd, type PodMetrics } from "@/lib/tauriCommands"; + +export interface UseMetricsResult { + /** Latest pod metrics from kubectl top pods. */ + metrics: PodMetrics[]; + /** True while the initial fetch is in flight. */ + loading: boolean; + /** Last error message returned from the backend, if any. */ + error: string | null; + /** Manually trigger a refresh. */ + refresh: () => Promise; + /** Lookup helper: find metrics for a pod by name. */ + getPodMetrics: (podName: string) => PodMetrics | undefined; +} + +const DEFAULT_INTERVAL_MS = 10_000; + +/** + * Subscribe to live pod metrics for a cluster/namespace. + * + * Refreshes every {@link intervalMs} milliseconds (default 10s). Automatically + * cancels the timer on unmount or when the cluster/namespace changes. Errors + * during a poll are surfaced via {@link UseMetricsResult.error} but do not + * stop subsequent polls. + * + * Pass `null`/`undefined`/empty string for `clusterId` or `namespace` to + * disable polling (the hook will return an empty list). + */ +export function useMetrics( + clusterId: string | null | undefined, + namespace: string | null | undefined, + intervalMs: number = DEFAULT_INTERVAL_MS +): UseMetricsResult { + const [metrics, setMetrics] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Track mount state so async fetches that resolve after unmount don't setState. + const mountedRef = useRef(true); + const timerRef = useRef | null>(null); + + const enabled = Boolean(clusterId) && Boolean(namespace); + + const fetchMetrics = useCallback(async () => { + if (!clusterId || !namespace) return; + try { + const result = await getPodMetricsCmd(clusterId, namespace); + if (!mountedRef.current) return; + setMetrics(result); + setError(null); + } catch (err) { + if (!mountedRef.current) return; + // Metrics-server may simply be missing - keep previous metrics, surface error. + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (mountedRef.current) setLoading(false); + } + }, [clusterId, namespace]); + + useEffect(() => { + mountedRef.current = true; + + // Reset state when inputs change. + setMetrics([]); + setError(null); + + if (!enabled) { + setLoading(false); + return () => { + mountedRef.current = false; + }; + } + + setLoading(true); + + // Kick off an initial fetch immediately. + void fetchMetrics(); + + // Then poll on the configured interval. + const tick = () => { + void fetchMetrics().finally(() => { + if (mountedRef.current) { + timerRef.current = setTimeout(tick, intervalMs); + } + }); + }; + timerRef.current = setTimeout(tick, intervalMs); + + return () => { + mountedRef.current = false; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [enabled, fetchMetrics, intervalMs]); + + const getPodMetrics = useCallback( + (podName: string) => metrics.find((m) => m.name === podName), + [metrics] + ); + + return { + metrics, + loading, + error, + refresh: fetchMetrics, + getPodMetrics, + }; +} + +export default useMetrics; diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index 0e80aa76..7889bfc9 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -1563,3 +1563,33 @@ export const terminatePtySessionCmd = (sessionId: string) => invoke("terminate_pty_session", { sessionId }); export const listPtySessionsCmd = () => invoke("list_pty_sessions", {}); + +// ─── Metrics ───────────────────────────────────────────────────────────────── + +export interface ContainerMetrics { + name: string; + cpu: string; + memory: string; +} + +export interface PodMetrics { + name: string; + namespace: string; + containers: ContainerMetrics[]; + cpu: string; + memory: string; +} + +export interface NodeMetrics { + name: string; + cpu: string; + memory: string; + cpu_percent: number; + memory_percent: number; +} + +export const getPodMetricsCmd = (clusterId: string, namespace: string) => + invoke("get_pod_metrics", { clusterId, namespace }); + +export const getNodeMetricsCmd = (clusterId: string) => + invoke("get_node_metrics", { clusterId });