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
This commit is contained in:
Shaun Arman 2026-06-09 17:05:24 -05:00
parent 0603910c1f
commit 719a5d421d
15 changed files with 941 additions and 78 deletions

30
package-lock.json generated
View File

@ -16,10 +16,12 @@
"@tauri-apps/plugin-stronghold": "^2", "@tauri-apps/plugin-stronghold": "^2",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"ansi-to-react": "^6.2.6", "ansi-to-react": "^6.2.6",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7", "class-variance-authority": "^0.7",
"clsx": "^2", "clsx": "^2",
"lucide-react": "latest", "lucide-react": "latest",
"react": "^19", "react": "^19",
"react-chartjs-2": "^5.3.1",
"react-diff-viewer-continued": "^4", "react-diff-viewer-continued": "^4",
"react-dom": "^19", "react-dom": "^19",
"react-markdown": "^10", "react-markdown": "^10",
@ -1941,6 +1943,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@monaco-editor/loader": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
@ -4750,6 +4758,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cheerio": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
@ -11626,6 +11646,16 @@
"node": ">=0.10.0" "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": { "node_modules/react-diff-viewer-continued": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-4.2.2.tgz", "resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-4.2.2.tgz",

View File

@ -23,10 +23,12 @@
"@tauri-apps/plugin-stronghold": "^2", "@tauri-apps/plugin-stronghold": "^2",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"ansi-to-react": "^6.2.6", "ansi-to-react": "^6.2.6",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7", "class-variance-authority": "^0.7",
"clsx": "^2", "clsx": "^2",
"lucide-react": "latest", "lucide-react": "latest",
"react": "^19", "react": "^19",
"react-chartjs-2": "^5.3.1",
"react-diff-viewer-continued": "^4", "react-diff-viewer-continued": "^4",
"react-dom": "^19", "react-dom": "^19",
"react-markdown": "^10", "react-markdown": "^10",

View File

@ -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<Vec<PodMetrics>, 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<Vec<NodeMetrics>, 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}"))
}

View File

@ -6,5 +6,6 @@ pub mod docs;
pub mod image; pub mod image;
pub mod integrations; pub mod integrations;
pub mod kube; pub mod kube;
pub mod metrics;
pub mod shell; pub mod shell;
pub mod system; pub mod system;

View File

@ -6,6 +6,7 @@ pub mod docs;
pub mod integrations; pub mod integrations;
pub mod kube; pub mod kube;
pub mod mcp; pub mod mcp;
pub mod metrics;
pub mod ollama; pub mod ollama;
pub mod pii; pub mod pii;
pub mod shell; pub mod shell;
@ -281,6 +282,9 @@ pub fn run() {
commands::kube::helm_list_releases, commands::kube::helm_list_releases,
commands::kube::helm_uninstall, commands::kube::helm_uninstall,
commands::kube::helm_rollback, commands::kube::helm_rollback,
// Kubernetes Metrics
commands::metrics::get_pod_metrics,
commands::metrics::get_node_metrics,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("Error running Troubleshooting and RCA Assistant application"); .expect("Error running Troubleshooting and RCA Assistant application");

View File

@ -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<ContainerMetrics>,
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<Vec<PodMetrics>> {
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<Vec<NodeMetrics>> {
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::<u64>().unwrap_or(0)
} else if cpu.ends_with('u') {
cpu.trim_end_matches('u').parse::<u64>().unwrap_or(0) * 1000
} else if cpu.ends_with('m') {
cpu.trim_end_matches('m').parse::<u64>().unwrap_or(0) * 1_000_000
} else {
cpu.parse::<u64>().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::<u64>().unwrap_or(0)
} else if memory.ends_with("Mi") {
memory.trim_end_matches("Mi").parse::<u64>().unwrap_or(0) * 1024
} else if memory.ends_with("Gi") {
memory.trim_end_matches("Gi").parse::<u64>().unwrap_or(0) * 1024 * 1024
} else if memory.ends_with("Ti") {
memory.trim_end_matches("Ti").parse::<u64>().unwrap_or(0) * 1024 * 1024 * 1024
} else {
memory.parse::<u64>().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");
}
}

View File

@ -0,0 +1,3 @@
pub mod client;
pub use client::{ContainerMetrics, NodeMetrics, PodMetrics};

View File

@ -1,6 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button } from "@/components/ui";
import { RotateCcw, Pencil, Trash2, FileText } from "lucide-react"; import { RotateCcw, Pencil, Trash2, FileText, Settings } from "lucide-react";
import type { DaemonSetInfo } from "@/lib/tauriCommands"; import type { DaemonSetInfo } from "@/lib/tauriCommands";
import { import {
restartDaemonsetCmd, restartDaemonsetCmd,
@ -11,6 +11,9 @@ import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal"; import { EditResourceModal } from "./EditResourceModal";
import { WorkloadLogsModal } from "./WorkloadLogsModal"; import { WorkloadLogsModal } from "./WorkloadLogsModal";
import { useColumnConfig } from "@/hooks/useColumnConfig";
import { DEFAULT_COLUMNS } from "@/config/defaultColumns";
import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal";
interface DaemonSetListProps { interface DaemonSetListProps {
daemonsets: DaemonSetInfo[]; daemonsets: DaemonSetInfo[];
@ -30,6 +33,11 @@ export function DaemonSetList({ daemonsets, clusterId, namespace: _namespace, on
const [activeModal, setActiveModal] = useState<ActiveModal>(null); const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isActing, setIsActing] = useState(false); const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [showColumnConfig, setShowColumnConfig] = useState(false);
// Configurable columns
const columnConfig = useColumnConfig("daemonsets", DEFAULT_COLUMNS.daemonsets);
const { isColumnVisible } = columnConfig;
const openEdit = async (ds: DaemonSetInfo) => { const openEdit = async (ds: DaemonSetInfo) => {
setActionError(null); setActionError(null);
@ -72,37 +80,60 @@ export function DaemonSetList({ daemonsets, clusterId, namespace: _namespace, on
{actionError && ( {actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p> <p className="mb-2 text-sm text-destructive">{actionError}</p>
)} )}
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground">
{daemonsets.length} {daemonsets.length === 1 ? "daemonset" : "daemonsets"}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowColumnConfig(true)}
className="flex items-center gap-1"
>
<Settings className="h-3.5 w-3.5" />
Columns
</Button>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> {isColumnVisible("name") && <TableHead>Name</TableHead>}
<TableHead>Desired</TableHead> {isColumnVisible("namespace") && <TableHead>Namespace</TableHead>}
<TableHead>Current</TableHead> {isColumnVisible("desired") && <TableHead>Desired</TableHead>}
<TableHead>Ready</TableHead> {isColumnVisible("current") && <TableHead>Current</TableHead>}
<TableHead>Up-to-date</TableHead> {isColumnVisible("ready") && <TableHead>Ready</TableHead>}
<TableHead>Available</TableHead> {isColumnVisible("upToDate") && <TableHead>Up-to-date</TableHead>}
<TableHead>Age</TableHead> {isColumnVisible("available") && <TableHead>Available</TableHead>}
<TableHead className="text-right">Actions</TableHead> {isColumnVisible("age") && <TableHead>Age</TableHead>}
{isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{daemonsets.length === 0 ? ( {daemonsets.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground"> <TableCell colSpan={9} className="text-center text-muted-foreground">
No daemonsets found No daemonsets found
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
daemonsets.map((ds) => ( daemonsets.map((ds) => (
<TableRow key={ds.name}> <TableRow key={ds.name}>
{isColumnVisible("name") && (
<TableCell className="font-medium">{ds.name}</TableCell> <TableCell className="font-medium">{ds.name}</TableCell>
<TableCell>{ds.desired}</TableCell> )}
<TableCell>{ds.current}</TableCell> {isColumnVisible("namespace") && (
<TableCell>{ds.ready}</TableCell> <TableCell className="text-muted-foreground">{ds.namespace}</TableCell>
<TableCell>{ds.up_to_date}</TableCell> )}
<TableCell>{ds.available}</TableCell> {isColumnVisible("desired") && <TableCell>{ds.desired}</TableCell>}
{isColumnVisible("current") && <TableCell>{ds.current}</TableCell>}
{isColumnVisible("ready") && <TableCell>{ds.ready}</TableCell>}
{isColumnVisible("upToDate") && <TableCell>{ds.up_to_date}</TableCell>}
{isColumnVisible("available") && <TableCell>{ds.available}</TableCell>}
{isColumnVisible("age") && (
<TableCell className="text-muted-foreground">{ds.age}</TableCell> <TableCell className="text-muted-foreground">{ds.age}</TableCell>
)}
{isColumnVisible("actions") && (
<TableCell className="text-right"> <TableCell className="text-right">
<ResourceActionMenu <ResourceActionMenu
actions={[ actions={[
@ -130,6 +161,7 @@ export function DaemonSetList({ daemonsets, clusterId, namespace: _namespace, on
]} ]}
/> />
</TableCell> </TableCell>
)}
</TableRow> </TableRow>
)) ))
)} )}
@ -183,6 +215,24 @@ export function DaemonSetList({ daemonsets, clusterId, namespace: _namespace, on
onConfirm={handleDelete} onConfirm={handleDelete}
/> />
)} )}
<ColumnConfigModal
open={showColumnConfig}
onOpenChange={setShowColumnConfig}
resourceType="DaemonSets"
columnConfig={columnConfig}
columnLabels={{
name: "Name",
namespace: "Namespace",
desired: "Desired",
current: "Current",
ready: "Ready",
upToDate: "Up-to-date",
available: "Available",
age: "Age",
actions: "Actions",
}}
/>
</> </>
); );
} }

View File

@ -1,6 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button } from "@/components/ui";
import { Scale, RotateCcw, Undo2, Pencil, Trash2, FileText } from "lucide-react"; import { Scale, RotateCcw, Undo2, Pencil, Trash2, FileText, Settings } from "lucide-react";
import type { DeploymentInfo } from "@/lib/tauriCommands"; import type { DeploymentInfo } from "@/lib/tauriCommands";
import { import {
scaleDeploymentCmd, scaleDeploymentCmd,
@ -14,6 +14,9 @@ import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { ScaleModal } from "./ScaleModal"; import { ScaleModal } from "./ScaleModal";
import { EditResourceModal } from "./EditResourceModal"; import { EditResourceModal } from "./EditResourceModal";
import { WorkloadLogsModal } from "./WorkloadLogsModal"; import { WorkloadLogsModal } from "./WorkloadLogsModal";
import { useColumnConfig } from "@/hooks/useColumnConfig";
import { DEFAULT_COLUMNS } from "@/config/defaultColumns";
import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal";
interface DeploymentListProps { interface DeploymentListProps {
deployments: DeploymentInfo[]; deployments: DeploymentInfo[];
@ -35,6 +38,11 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace,
const [activeModal, setActiveModal] = useState<ActiveModal>(null); const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isActing, setIsActing] = useState(false); const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [showColumnConfig, setShowColumnConfig] = useState(false);
// Configurable columns
const columnConfig = useColumnConfig("deployments", DEFAULT_COLUMNS.deployments);
const { isColumnVisible } = columnConfig;
const openEdit = async (deployment: DeploymentInfo) => { const openEdit = async (deployment: DeploymentInfo) => {
setActionError(null); setActionError(null);
@ -91,17 +99,31 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace,
{actionError && ( {actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p> <p className="mb-2 text-sm text-destructive">{actionError}</p>
)} )}
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground">
{deployments.length} {deployments.length === 1 ? "deployment" : "deployments"}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowColumnConfig(true)}
className="flex items-center gap-1"
>
<Settings className="h-3.5 w-3.5" />
Columns
</Button>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> {isColumnVisible("name") && <TableHead>Name</TableHead>}
<TableHead>Ready</TableHead> {isColumnVisible("namespace") && <TableHead>Namespace</TableHead>}
<TableHead>Up-to-date</TableHead> {isColumnVisible("ready") && <TableHead>Ready</TableHead>}
<TableHead>Available</TableHead> {isColumnVisible("upToDate") && <TableHead>Up-to-date</TableHead>}
<TableHead>Replicas</TableHead> {isColumnVisible("available") && <TableHead>Available</TableHead>}
<TableHead>Age</TableHead> {isColumnVisible("age") && <TableHead>Age</TableHead>}
<TableHead className="text-right">Actions</TableHead> {isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -114,12 +136,19 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace,
) : ( ) : (
deployments.map((deployment) => ( deployments.map((deployment) => (
<TableRow key={deployment.name}> <TableRow key={deployment.name}>
{isColumnVisible("name") && (
<TableCell className="font-medium">{deployment.name}</TableCell> <TableCell className="font-medium">{deployment.name}</TableCell>
<TableCell>{deployment.ready}</TableCell> )}
<TableCell>{deployment.up_to_date}</TableCell> {isColumnVisible("namespace") && (
<TableCell>{deployment.available}</TableCell> <TableCell className="text-muted-foreground">{deployment.namespace}</TableCell>
<TableCell>{deployment.replicas}</TableCell> )}
{isColumnVisible("ready") && <TableCell>{deployment.ready}</TableCell>}
{isColumnVisible("upToDate") && <TableCell>{deployment.up_to_date}</TableCell>}
{isColumnVisible("available") && <TableCell>{deployment.available}</TableCell>}
{isColumnVisible("age") && (
<TableCell className="text-muted-foreground">{deployment.age}</TableCell> <TableCell className="text-muted-foreground">{deployment.age}</TableCell>
)}
{isColumnVisible("actions") && (
<TableCell className="text-right"> <TableCell className="text-right">
<ResourceActionMenu <ResourceActionMenu
actions={[ actions={[
@ -157,6 +186,7 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace,
]} ]}
/> />
</TableCell> </TableCell>
)}
</TableRow> </TableRow>
)) ))
)} )}
@ -238,6 +268,22 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace,
onConfirm={handleDelete} onConfirm={handleDelete}
/> />
)} )}
<ColumnConfigModal
open={showColumnConfig}
onOpenChange={setShowColumnConfig}
resourceType="Deployments"
columnConfig={columnConfig}
columnLabels={{
name: "Name",
namespace: "Namespace",
ready: "Ready",
upToDate: "Up-to-date",
available: "Available",
age: "Age",
actions: "Actions",
}}
/>
</> </>
); );
} }

View File

@ -1,12 +1,15 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button } from "@/components/ui";
import { Pencil, Trash2, FileText } from "lucide-react"; import { Pencil, Trash2, FileText, Settings } from "lucide-react";
import type { JobInfo } from "@/lib/tauriCommands"; import type { JobInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu"; import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal"; import { EditResourceModal } from "./EditResourceModal";
import { WorkloadLogsModal } from "./WorkloadLogsModal"; import { WorkloadLogsModal } from "./WorkloadLogsModal";
import { useColumnConfig } from "@/hooks/useColumnConfig";
import { DEFAULT_COLUMNS } from "@/config/defaultColumns";
import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal";
interface JobListProps { interface JobListProps {
jobs: JobInfo[]; jobs: JobInfo[];
@ -33,6 +36,11 @@ export function JobList({
const [activeModal, setActiveModal] = useState<ActiveModal>(null); const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [showColumnConfig, setShowColumnConfig] = useState(false);
// Configurable columns
const columnConfig = useColumnConfig("jobs", DEFAULT_COLUMNS.jobs);
const { isColumnVisible } = columnConfig;
const openEdit = async (job: JobInfo) => { const openEdit = async (job: JobInfo) => {
setActionError(null); setActionError(null);
@ -61,17 +69,31 @@ export function JobList({
{actionError && ( {actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p> <p className="mb-2 text-sm text-destructive">{actionError}</p>
)} )}
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground">
{jobs.length} {jobs.length === 1 ? "job" : "jobs"}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowColumnConfig(true)}
className="flex items-center gap-1"
>
<Settings className="h-3.5 w-3.5" />
Columns
</Button>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> {isColumnVisible("name") && <TableHead>Name</TableHead>}
<TableHead>Namespace</TableHead> {isColumnVisible("namespace") && <TableHead>Namespace</TableHead>}
<TableHead>Completions</TableHead> {isColumnVisible("completions") && <TableHead>Completions</TableHead>}
<TableHead>Duration</TableHead> {isColumnVisible("duration") && <TableHead>Duration</TableHead>}
<TableHead>Age</TableHead> {isColumnVisible("age") && <TableHead>Age</TableHead>}
<TableHead>Labels</TableHead> {isColumnVisible("labels") && <TableHead>Labels</TableHead>}
<TableHead className="text-right">Actions</TableHead> {isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -84,16 +106,25 @@ export function JobList({
) : ( ) : (
jobs.map((job) => ( jobs.map((job) => (
<TableRow key={`${job.name}-${job.namespace}`}> <TableRow key={`${job.name}-${job.namespace}`}>
{isColumnVisible("name") && (
<TableCell className="font-medium">{job.name}</TableCell> <TableCell className="font-medium">{job.name}</TableCell>
<TableCell>{job.namespace}</TableCell> )}
<TableCell>{job.completions}</TableCell> {isColumnVisible("namespace") && (
<TableCell>{job.duration}</TableCell> <TableCell className="text-muted-foreground">{job.namespace}</TableCell>
)}
{isColumnVisible("completions") && <TableCell>{job.completions}</TableCell>}
{isColumnVisible("duration") && <TableCell>{job.duration}</TableCell>}
{isColumnVisible("age") && (
<TableCell className="text-muted-foreground">{job.age}</TableCell> <TableCell className="text-muted-foreground">{job.age}</TableCell>
)}
{isColumnVisible("labels") && (
<TableCell> <TableCell>
{Object.entries(job.labels) {Object.entries(job.labels)
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join(", ")} .join(", ")}
</TableCell> </TableCell>
)}
{isColumnVisible("actions") && (
<TableCell className="text-right"> <TableCell className="text-right">
<ResourceActionMenu <ResourceActionMenu
actions={[ actions={[
@ -116,6 +147,7 @@ export function JobList({
]} ]}
/> />
</TableCell> </TableCell>
)}
</TableRow> </TableRow>
)) ))
)} )}
@ -157,6 +189,22 @@ export function JobList({
onConfirm={handleDelete} onConfirm={handleDelete}
/> />
)} )}
<ColumnConfigModal
open={showColumnConfig}
onOpenChange={setShowColumnConfig}
resourceType="Jobs"
columnConfig={columnConfig}
columnLabels={{
name: "Name",
namespace: "Namespace",
completions: "Completions",
duration: "Duration",
age: "Age",
labels: "Labels",
actions: "Actions",
}}
/>
</> </>
); );
} }

View File

@ -11,6 +11,7 @@ import { InteractiveShellModal } from "./InteractiveShellModal";
import { InteractiveAttachModal } from "./InteractiveAttachModal"; import { InteractiveAttachModal } from "./InteractiveAttachModal";
import { EditResourceModal } from "./EditResourceModal"; import { EditResourceModal } from "./EditResourceModal";
import { useColumnConfig } from "@/hooks/useColumnConfig"; import { useColumnConfig } from "@/hooks/useColumnConfig";
import { useMetrics } from "@/hooks/useMetrics";
import { DEFAULT_COLUMNS } from "@/config/defaultColumns"; import { DEFAULT_COLUMNS } from "@/config/defaultColumns";
import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal"; import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal";
import { QuickActionColumn } from "@/components/tables/QuickActionColumn"; import { QuickActionColumn } from "@/components/tables/QuickActionColumn";
@ -37,13 +38,17 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
const [editError, setEditError] = useState<string | null>(null); const [editError, setEditError] = useState<string | null>(null);
const [showColumnConfig, setShowColumnConfig] = useState(false); const [showColumnConfig, setShowColumnConfig] = useState(false);
// namespace prop is retained for API compatibility (parent uses it to drive list fetches)
void namespace;
// Configurable columns // Configurable columns
const columnConfig = useColumnConfig("pods", DEFAULT_COLUMNS.pods); const columnConfig = useColumnConfig("pods", DEFAULT_COLUMNS.pods);
const { isColumnVisible } = columnConfig; 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) => { const getPodStatusColor = (status: string) => {
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
case "running": case "running":
@ -122,18 +127,22 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
{isColumnVisible("age") && <TableHead>Age</TableHead>} {isColumnVisible("age") && <TableHead>Age</TableHead>}
{isColumnVisible("ip") && <TableHead>IP</TableHead>} {isColumnVisible("ip") && <TableHead>IP</TableHead>}
{isColumnVisible("node") && <TableHead>Node</TableHead>} {isColumnVisible("node") && <TableHead>Node</TableHead>}
{isColumnVisible("cpu") && <TableHead>CPU</TableHead>}
{isColumnVisible("memory") && <TableHead>Memory</TableHead>}
{isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>} {isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{pods.length === 0 ? ( {pods.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground"> <TableCell colSpan={11} className="text-center text-muted-foreground">
No pods found No pods found
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
pods.map((pod) => ( pods.map((pod) => {
const podMetrics = metricsEnabled ? getPodMetrics(pod.name) : undefined;
return (
<TableRow key={pod.name}> <TableRow key={pod.name}>
{isColumnVisible("name") && ( {isColumnVisible("name") && (
<TableCell className="font-medium">{pod.name}</TableCell> <TableCell className="font-medium">{pod.name}</TableCell>
@ -159,6 +168,16 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
{isColumnVisible("node") && ( {isColumnVisible("node") && (
<TableCell className="text-muted-foreground">{pod.node || "-"}</TableCell> <TableCell className="text-muted-foreground">{pod.node || "-"}</TableCell>
)} )}
{isColumnVisible("cpu") && (
<TableCell className="text-muted-foreground font-mono text-xs">
{podMetrics?.cpu ?? "-"}
</TableCell>
)}
{isColumnVisible("memory") && (
<TableCell className="text-muted-foreground font-mono text-xs">
{podMetrics?.memory ?? "-"}
</TableCell>
)}
{isColumnVisible("actions") && ( {isColumnVisible("actions") && (
<TableCell className="text-right"> <TableCell className="text-right">
<ResourceActionMenu <ResourceActionMenu
@ -204,7 +223,8 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
</TableCell> </TableCell>
)} )}
</TableRow> </TableRow>
)) );
})
)} )}
</TableBody> </TableBody>
</Table> </Table>
@ -290,6 +310,8 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
age: "Age", age: "Age",
ip: "IP Address", ip: "IP Address",
node: "Node", node: "Node",
cpu: "CPU",
memory: "Memory",
actions: "Actions", actions: "Actions",
}} }}
/> />

View File

@ -1,6 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button } from "@/components/ui";
import { Scale, RotateCcw, Pencil, Trash2, FileText } from "lucide-react"; import { Scale, RotateCcw, Pencil, Trash2, FileText, Settings } from "lucide-react";
import type { StatefulSetInfo } from "@/lib/tauriCommands"; import type { StatefulSetInfo } from "@/lib/tauriCommands";
import { import {
scaleStatefulsetCmd, scaleStatefulsetCmd,
@ -13,6 +13,9 @@ import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { ScaleModal } from "./ScaleModal"; import { ScaleModal } from "./ScaleModal";
import { EditResourceModal } from "./EditResourceModal"; import { EditResourceModal } from "./EditResourceModal";
import { WorkloadLogsModal } from "./WorkloadLogsModal"; import { WorkloadLogsModal } from "./WorkloadLogsModal";
import { useColumnConfig } from "@/hooks/useColumnConfig";
import { DEFAULT_COLUMNS } from "@/config/defaultColumns";
import { ColumnConfigModal } from "@/components/tables/ColumnConfigModal";
interface StatefulSetListProps { interface StatefulSetListProps {
statefulsets: StatefulSetInfo[]; statefulsets: StatefulSetInfo[];
@ -33,6 +36,11 @@ export function StatefulSetList({ statefulsets, clusterId, namespace: _namespace
const [activeModal, setActiveModal] = useState<ActiveModal>(null); const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isActing, setIsActing] = useState(false); const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [showColumnConfig, setShowColumnConfig] = useState(false);
// Configurable columns
const columnConfig = useColumnConfig("statefulsets", DEFAULT_COLUMNS.statefulsets);
const { isColumnVisible } = columnConfig;
const openEdit = async (ss: StatefulSetInfo) => { const openEdit = async (ss: StatefulSetInfo) => {
setActionError(null); setActionError(null);
@ -75,31 +83,54 @@ export function StatefulSetList({ statefulsets, clusterId, namespace: _namespace
{actionError && ( {actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p> <p className="mb-2 text-sm text-destructive">{actionError}</p>
)} )}
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground">
{statefulsets.length} {statefulsets.length === 1 ? "statefulset" : "statefulsets"}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowColumnConfig(true)}
className="flex items-center gap-1"
>
<Settings className="h-3.5 w-3.5" />
Columns
</Button>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> {isColumnVisible("name") && <TableHead>Name</TableHead>}
<TableHead>Ready</TableHead> {isColumnVisible("namespace") && <TableHead>Namespace</TableHead>}
<TableHead>Replicas</TableHead> {isColumnVisible("ready") && <TableHead>Ready</TableHead>}
<TableHead>Age</TableHead> {isColumnVisible("replicas") && <TableHead>Replicas</TableHead>}
<TableHead className="text-right">Actions</TableHead> {isColumnVisible("age") && <TableHead>Age</TableHead>}
{isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{statefulsets.length === 0 ? ( {statefulsets.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground"> <TableCell colSpan={6} className="text-center text-muted-foreground">
No statefulsets found No statefulsets found
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
statefulsets.map((ss) => ( statefulsets.map((ss) => (
<TableRow key={ss.name}> <TableRow key={ss.name}>
{isColumnVisible("name") && (
<TableCell className="font-medium">{ss.name}</TableCell> <TableCell className="font-medium">{ss.name}</TableCell>
<TableCell>{ss.ready}</TableCell> )}
<TableCell>{ss.replicas}</TableCell> {isColumnVisible("namespace") && (
<TableCell className="text-muted-foreground">{ss.namespace}</TableCell>
)}
{isColumnVisible("ready") && <TableCell>{ss.ready}</TableCell>}
{isColumnVisible("replicas") && <TableCell>{ss.replicas}</TableCell>}
{isColumnVisible("age") && (
<TableCell className="text-muted-foreground">{ss.age}</TableCell> <TableCell className="text-muted-foreground">{ss.age}</TableCell>
)}
{isColumnVisible("actions") && (
<TableCell className="text-right"> <TableCell className="text-right">
<ResourceActionMenu <ResourceActionMenu
actions={[ actions={[
@ -132,6 +163,7 @@ export function StatefulSetList({ statefulsets, clusterId, namespace: _namespace
]} ]}
/> />
</TableCell> </TableCell>
)}
</TableRow> </TableRow>
)) ))
)} )}
@ -201,6 +233,21 @@ export function StatefulSetList({ statefulsets, clusterId, namespace: _namespace
onConfirm={handleDelete} onConfirm={handleDelete}
/> />
)} )}
<ColumnConfigModal
open={showColumnConfig}
onOpenChange={setShowColumnConfig}
resourceType="StatefulSets"
columnConfig={columnConfig}
columnLabels={{
name: "Name",
namespace: "Namespace",
ready: "Ready",
replicas: "Replicas",
age: "Age",
actions: "Actions",
}}
/>
</> </>
); );
} }

View File

@ -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<MetricsChartType, { border: string; background: string; label: string }> = {
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 (
<div
className="flex items-center justify-center text-sm text-muted-foreground border rounded-lg bg-card"
style={{ height }}
>
No metrics data available
</div>
);
}
return (
<div className="bg-card border rounded-lg p-3" style={{ height }}>
<Line data={chartData} options={options} />
</div>
);
}
export default MetricsChart;

113
src/hooks/useMetrics.ts Normal file
View File

@ -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<void>;
/** 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<PodMetrics[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Track mount state so async fetches that resolve after unmount don't setState.
const mountedRef = useRef(true);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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;

View File

@ -1563,3 +1563,33 @@ export const terminatePtySessionCmd = (sessionId: string) =>
invoke<void>("terminate_pty_session", { sessionId }); invoke<void>("terminate_pty_session", { sessionId });
export const listPtySessionsCmd = () => invoke<PtySessionInfo[]>("list_pty_sessions", {}); export const listPtySessionsCmd = () => invoke<PtySessionInfo[]>("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<PodMetrics[]>("get_pod_metrics", { clusterId, namespace });
export const getNodeMetricsCmd = (clusterId: string) =>
invoke<NodeMetrics[]>("get_node_metrics", { clusterId });