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:
parent
0603910c1f
commit
719a5d421d
30
package-lock.json
generated
30
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
108
src-tauri/src/commands/metrics.rs
Normal file
108
src-tauri/src/commands/metrics.rs
Normal 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}"))
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
237
src-tauri/src/metrics/client.rs
Normal file
237
src-tauri/src/metrics/client.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
3
src-tauri/src/metrics/mod.rs
Normal file
3
src-tauri/src/metrics/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod client;
|
||||
|
||||
pub use client::{ContainerMetrics, NodeMetrics, PodMetrics};
|
||||
@ -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<ActiveModal>(null);
|
||||
const [isActing, setIsActing] = useState(false);
|
||||
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) => {
|
||||
setActionError(null);
|
||||
@ -72,38 +80,61 @@ export function DaemonSetList({ daemonsets, clusterId, namespace: _namespace, on
|
||||
{actionError && (
|
||||
<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">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Desired</TableHead>
|
||||
<TableHead>Current</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Up-to-date</TableHead>
|
||||
<TableHead>Available</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
{isColumnVisible("name") && <TableHead>Name</TableHead>}
|
||||
{isColumnVisible("namespace") && <TableHead>Namespace</TableHead>}
|
||||
{isColumnVisible("desired") && <TableHead>Desired</TableHead>}
|
||||
{isColumnVisible("current") && <TableHead>Current</TableHead>}
|
||||
{isColumnVisible("ready") && <TableHead>Ready</TableHead>}
|
||||
{isColumnVisible("upToDate") && <TableHead>Up-to-date</TableHead>}
|
||||
{isColumnVisible("available") && <TableHead>Available</TableHead>}
|
||||
{isColumnVisible("age") && <TableHead>Age</TableHead>}
|
||||
{isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{daemonsets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={9} className="text-center text-muted-foreground">
|
||||
No daemonsets found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
daemonsets.map((ds) => (
|
||||
<TableRow key={ds.name}>
|
||||
<TableCell className="font-medium">{ds.name}</TableCell>
|
||||
<TableCell>{ds.desired}</TableCell>
|
||||
<TableCell>{ds.current}</TableCell>
|
||||
<TableCell>{ds.ready}</TableCell>
|
||||
<TableCell>{ds.up_to_date}</TableCell>
|
||||
<TableCell>{ds.available}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{ds.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isColumnVisible("name") && (
|
||||
<TableCell className="font-medium">{ds.name}</TableCell>
|
||||
)}
|
||||
{isColumnVisible("namespace") && (
|
||||
<TableCell className="text-muted-foreground">{ds.namespace}</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>
|
||||
)}
|
||||
{isColumnVisible("actions") && (
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
@ -129,7 +160,8 @@ export function DaemonSetList({ daemonsets, clusterId, namespace: _namespace, on
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
@ -183,6 +215,24 @@ export function DaemonSetList({ daemonsets, clusterId, namespace: _namespace, on
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<ActiveModal>(null);
|
||||
const [isActing, setIsActing] = useState(false);
|
||||
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) => {
|
||||
setActionError(null);
|
||||
@ -91,17 +99,31 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace,
|
||||
{actionError && (
|
||||
<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">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Up-to-date</TableHead>
|
||||
<TableHead>Available</TableHead>
|
||||
<TableHead>Replicas</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
{isColumnVisible("name") && <TableHead>Name</TableHead>}
|
||||
{isColumnVisible("namespace") && <TableHead>Namespace</TableHead>}
|
||||
{isColumnVisible("ready") && <TableHead>Ready</TableHead>}
|
||||
{isColumnVisible("upToDate") && <TableHead>Up-to-date</TableHead>}
|
||||
{isColumnVisible("available") && <TableHead>Available</TableHead>}
|
||||
{isColumnVisible("age") && <TableHead>Age</TableHead>}
|
||||
{isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -114,13 +136,20 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace,
|
||||
) : (
|
||||
deployments.map((deployment) => (
|
||||
<TableRow key={deployment.name}>
|
||||
<TableCell className="font-medium">{deployment.name}</TableCell>
|
||||
<TableCell>{deployment.ready}</TableCell>
|
||||
<TableCell>{deployment.up_to_date}</TableCell>
|
||||
<TableCell>{deployment.available}</TableCell>
|
||||
<TableCell>{deployment.replicas}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{deployment.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isColumnVisible("name") && (
|
||||
<TableCell className="font-medium">{deployment.name}</TableCell>
|
||||
)}
|
||||
{isColumnVisible("namespace") && (
|
||||
<TableCell className="text-muted-foreground">{deployment.namespace}</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>
|
||||
)}
|
||||
{isColumnVisible("actions") && (
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
@ -156,7 +185,8 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
@ -238,6 +268,22 @@ export function DeploymentList({ deployments, clusterId, namespace: _namespace,
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
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) => {
|
||||
setActionError(null);
|
||||
@ -61,17 +69,31 @@ export function JobList({
|
||||
{actionError && (
|
||||
<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">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Completions</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead>Labels</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
{isColumnVisible("name") && <TableHead>Name</TableHead>}
|
||||
{isColumnVisible("namespace") && <TableHead>Namespace</TableHead>}
|
||||
{isColumnVisible("completions") && <TableHead>Completions</TableHead>}
|
||||
{isColumnVisible("duration") && <TableHead>Duration</TableHead>}
|
||||
{isColumnVisible("age") && <TableHead>Age</TableHead>}
|
||||
{isColumnVisible("labels") && <TableHead>Labels</TableHead>}
|
||||
{isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -84,17 +106,26 @@ export function JobList({
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<TableRow key={`${job.name}-${job.namespace}`}>
|
||||
<TableCell className="font-medium">{job.name}</TableCell>
|
||||
<TableCell>{job.namespace}</TableCell>
|
||||
<TableCell>{job.completions}</TableCell>
|
||||
<TableCell>{job.duration}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{job.age}</TableCell>
|
||||
<TableCell>
|
||||
{Object.entries(job.labels)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isColumnVisible("name") && (
|
||||
<TableCell className="font-medium">{job.name}</TableCell>
|
||||
)}
|
||||
{isColumnVisible("namespace") && (
|
||||
<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>
|
||||
)}
|
||||
{isColumnVisible("labels") && (
|
||||
<TableCell>
|
||||
{Object.entries(job.labels)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
)}
|
||||
{isColumnVisible("actions") && (
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
@ -115,7 +146,8 @@ export function JobList({
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
@ -157,6 +189,22 @@ export function JobList({
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string | null>(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") && <TableHead>Age</TableHead>}
|
||||
{isColumnVisible("ip") && <TableHead>IP</TableHead>}
|
||||
{isColumnVisible("node") && <TableHead>Node</TableHead>}
|
||||
{isColumnVisible("cpu") && <TableHead>CPU</TableHead>}
|
||||
{isColumnVisible("memory") && <TableHead>Memory</TableHead>}
|
||||
{isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pods.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={11} className="text-center text-muted-foreground">
|
||||
No pods found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pods.map((pod) => (
|
||||
pods.map((pod) => {
|
||||
const podMetrics = metricsEnabled ? getPodMetrics(pod.name) : undefined;
|
||||
return (
|
||||
<TableRow key={pod.name}>
|
||||
{isColumnVisible("name") && (
|
||||
<TableCell className="font-medium">{pod.name}</TableCell>
|
||||
@ -159,6 +168,16 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
|
||||
{isColumnVisible("node") && (
|
||||
<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") && (
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
@ -204,7 +223,8 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@ -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",
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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<ActiveModal>(null);
|
||||
const [isActing, setIsActing] = useState(false);
|
||||
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) => {
|
||||
setActionError(null);
|
||||
@ -75,32 +83,55 @@ export function StatefulSetList({ statefulsets, clusterId, namespace: _namespace
|
||||
{actionError && (
|
||||
<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">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Replicas</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
{isColumnVisible("name") && <TableHead>Name</TableHead>}
|
||||
{isColumnVisible("namespace") && <TableHead>Namespace</TableHead>}
|
||||
{isColumnVisible("ready") && <TableHead>Ready</TableHead>}
|
||||
{isColumnVisible("replicas") && <TableHead>Replicas</TableHead>}
|
||||
{isColumnVisible("age") && <TableHead>Age</TableHead>}
|
||||
{isColumnVisible("actions") && <TableHead className="text-right">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{statefulsets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No statefulsets found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
statefulsets.map((ss) => (
|
||||
<TableRow key={ss.name}>
|
||||
<TableCell className="font-medium">{ss.name}</TableCell>
|
||||
<TableCell>{ss.ready}</TableCell>
|
||||
<TableCell>{ss.replicas}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{ss.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isColumnVisible("name") && (
|
||||
<TableCell className="font-medium">{ss.name}</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>
|
||||
)}
|
||||
{isColumnVisible("actions") && (
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
@ -131,7 +162,8 @@ export function StatefulSetList({ statefulsets, clusterId, namespace: _namespace
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
@ -201,6 +233,21 @@ export function StatefulSetList({ statefulsets, clusterId, namespace: _namespace
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ColumnConfigModal
|
||||
open={showColumnConfig}
|
||||
onOpenChange={setShowColumnConfig}
|
||||
resourceType="StatefulSets"
|
||||
columnConfig={columnConfig}
|
||||
columnLabels={{
|
||||
name: "Name",
|
||||
namespace: "Namespace",
|
||||
ready: "Ready",
|
||||
replicas: "Replicas",
|
||||
age: "Age",
|
||||
actions: "Actions",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
122
src/components/metrics/MetricsChart.tsx
Normal file
122
src/components/metrics/MetricsChart.tsx
Normal 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
113
src/hooks/useMetrics.ts
Normal 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;
|
||||
@ -1563,3 +1563,33 @@ export const terminatePtySessionCmd = (sessionId: string) =>
|
||||
invoke<void>("terminate_pty_session", { sessionId });
|
||||
|
||||
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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user