From ef3709ffe915221a6095204958772f5ce49753e4 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Sun, 7 Jun 2026 17:39:07 -0500 Subject: [PATCH 1/2] fix(kube): bridge kubeconfig storage to in-memory cluster map and fix UI issues Resolves four bugs in the Kubernetes management interface: 1. **Cluster not found error** - commands/kube.rs::list_nodes (and all other kube resource commands) look up clusters from state.clusters (in-memory map) which was never populated from the kubeconfig_files table. Add a new connect_cluster_from_kubeconfig Tauri command that reads the encrypted kubeconfig from the DB, decrypts it, and inserts a ClusterClient into state.clusters. Wire it into KubernetesPage on initial load and cluster change so the in-memory map is always populated before any kube command runs. 2. **Dropdown selection has no effect** - same root cause as #1; activating a kubeconfig only updated the DB flag but never loaded the client into memory. handleClusterChange now calls connectClusterFromKubeconfigCmd after activation. 3. **GUID shown instead of cluster name** - ClusterOverview displayed the raw internal UUID as the page subtitle. Now accepts a clusterName prop (populated from kubeconfig.context) and renders that instead. ClusterDetails similarly changed to show kubeconfig.context in the header, not the UUID. 4. **Bell icon not clickable** - Hotbar bell button had no onClick handler. Add optional onNotifications / notificationCount props; badge count is now dynamic rather than hardcoded. KubernetesPage wires up a notifications dialog showing active cluster context and a link to the Events section. All changes follow TDD: failing tests written first, then implementation. --- src-tauri/src/commands/kube.rs | 38 ++++++++ src-tauri/src/lib.rs | 1 + src/components/Kubernetes/ClusterDetails.tsx | 4 +- src/components/Kubernetes/ClusterOverview.tsx | 7 +- src/components/Kubernetes/Hotbar.tsx | 19 ++-- src/lib/tauriCommands.ts | 3 + src/pages/Kubernetes/KubernetesPage.tsx | 46 +++++++++- tests/unit/ClusterDetails.test.tsx | 15 ++++ tests/unit/ClusterOverview.test.tsx | 22 +++++ tests/unit/Hotbar.test.tsx | 87 +++++++++++++++++++ tests/unit/kubernetesCommands.test.ts | 29 +++++++ 11 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 tests/unit/Hotbar.test.tsx diff --git a/src-tauri/src/commands/kube.rs b/src-tauri/src/commands/kube.rs index 880cd1fb..b130d98a 100644 --- a/src-tauri/src/commands/kube.rs +++ b/src-tauri/src/commands/kube.rs @@ -156,6 +156,44 @@ fn extract_server_url(content: &str) -> Result { .ok_or_else(|| "Server URL not found in cluster".to_string()) } +/// Load a stored kubeconfig into the in-memory cluster map so all kube commands can use it. +/// +/// This bridges the kubeconfig_files table (encrypted storage) with the in-memory +/// state.clusters map that every kubernetes command requires. +#[tauri::command] +pub async fn connect_cluster_from_kubeconfig( + id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + // Read name and encrypted content from DB + let (name, encrypted_content) = { + let db = state.db.lock().map_err(|e| e.to_string())?; + db.query_row( + "SELECT name, encrypted_content FROM kubeconfig_files WHERE id = ?1", + rusqlite::params![&id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .map_err(|e| format!("Kubeconfig {id} not found in storage: {e}"))? + }; + + let content = crate::integrations::auth::decrypt_token(&encrypted_content)?; + let context = extract_context(&content)?; + let server_url = extract_server_url(&content).unwrap_or_default(); + + let client = ClusterClient::new( + id.clone(), + name, + context, + server_url, + Arc::new(content), + ); + + let mut clusters = state.clusters.lock().await; + clusters.insert(id, client); + + Ok(()) +} + #[tauri::command] pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> { // Check existence in memory BEFORE touching the DB diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f20388a3..682e119a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -179,6 +179,7 @@ pub fn run() { commands::shell::check_kubectl_installed, // Kubernetes Management commands::kube::add_cluster, + commands::kube::connect_cluster_from_kubeconfig, commands::kube::remove_cluster, commands::kube::list_clusters, commands::kube::start_port_forward, diff --git a/src/components/Kubernetes/ClusterDetails.tsx b/src/components/Kubernetes/ClusterDetails.tsx index 2bde2ddd..1f173c95 100644 --- a/src/components/Kubernetes/ClusterDetails.tsx +++ b/src/components/Kubernetes/ClusterDetails.tsx @@ -112,7 +112,9 @@ export function ClusterDetails({ clusterId }: ClusterDetailsProps) {

Cluster Details

-

Cluster ID: {clusterId}

+

+ {kubeconfig.context} +

+ )} +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + export default function ShellExecution() { const [kubectlStatus, setKubectlStatus] = useState(null); const [executions, setExecutions] = useState([]); + const [classifierRules, setClassifierRules] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); @@ -36,30 +184,44 @@ export default function ShellExecution() { } }; + const loadClassifierRules = async () => { + try { + const rules = await getClassifierRulesCmd(); + setClassifierRules(rules); + } catch { + // Non-fatal — fall back to empty state; the tier cards will just show 0 commands + } + }; + useEffect(() => { loadKubectlStatus(); loadExecutions(); + loadClassifierRules(); }, []); const getTierBadge = (tier: number) => { - const colors = { + const colors: Record = { 1: 'bg-green-100 text-green-700 border-green-300', 2: 'bg-yellow-100 text-yellow-700 border-yellow-300', 3: 'bg-red-100 text-red-700 border-red-300', }; - return colors[tier as keyof typeof colors] || colors[1]; + return colors[tier] ?? colors[1]; }; const getStatusBadge = (status: string) => { - const config = { + const config: Record = { auto: { label: 'Auto-executed', color: 'bg-blue-100 text-blue-700 border-blue-300' }, approved: { label: 'Approved', color: 'bg-green-100 text-green-700 border-green-300' }, denied: { label: 'Denied', color: 'bg-red-100 text-red-700 border-red-300' }, }; - const statusConfig = config[status as keyof typeof config] || config.auto; - return statusConfig; + return config[status] ?? config.auto; }; + // Build grouped command lists for each tier (empty arrays when rules not loaded) + const tier1Groups = classifierRules ? buildTier1Groups(classifierRules) : []; + const tier2Groups = classifierRules ? buildTier2Groups(classifierRules) : []; + const tier3Groups = classifierRules ? buildTier3Groups(classifierRules) : []; + return (
@@ -126,7 +288,7 @@ export default function ShellExecution() { - {/* Safety Architecture */} + {/* Safety Architecture — driven by live classifier data */} @@ -134,42 +296,21 @@ export default function ShellExecution() { Safety Architecture - +

- Commands are automatically classified into three safety tiers: + Commands are automatically classified into three safety tiers. The lists below + reflect the active classifier rules — they update whenever a rule is added or removed.

-
-
- Tier 1 -
-
Auto-execute (Read-only)
-
- kubectl get, describe, logs | cat, grep, ls -
-
-
+ {!classifierRules && ( +

Loading classifier rules…

+ )} -
- Tier 2 -
-
Require approval (Mutating)
-
- kubectl apply, delete, scale | ssh, chmod, systemctl restart -
-
-
- -
- Tier 3 -
-
Always deny (Destructive)
-
- rm -rf, shutdown, mkfs, dd -
-
-
-
+ {TIER_CONFIG.map((cfg) => { + const groups = + cfg.tier === 1 ? tier1Groups : cfg.tier === 2 ? tier2Groups : tier3Groups; + return ; + })}
@@ -201,12 +342,8 @@ export default function ShellExecution() {
- - T{exec.tier} - - - {statusConfig.label} - + T{exec.tier} + {statusConfig.label}
diff --git a/tests/unit/shellCommands.test.ts b/tests/unit/shellCommands.test.ts new file mode 100644 index 00000000..ee851c37 --- /dev/null +++ b/tests/unit/shellCommands.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import * as tauriCommands from "@/lib/tauriCommands"; + +vi.mock("@tauri-apps/api/core"); + +type MockedFunction unknown> = T & { + mockResolvedValue: (value: unknown) => void; + mockRejectedValue: (error: Error) => void; +}; + +const mockClassifierRules = { + tier1_kubectl: ["get", "describe", "logs", "explain", "api-resources", "api-versions", "cluster-info", "top", "version"], + tier1_systemctl: ["status", "is-active", "is-enabled", "list-units", "list-unit-files"], + tier1_proxmox: ["status", "get"], + tier1_general: ["cat", "grep", "ls", "find", "df", "free", "ps", "dig", "nslookup", "ldapsearch"], + tier2_kubectl: ["apply", "delete", "edit", "scale", "rollout", "exec", "cp", "port-forward"], + tier2_systemctl: ["restart", "stop", "start", "enable", "disable", "reload", "mask", "unmask"], + tier2_proxmox: ["migrate", "create", "set", "delete", "start", "stop"], + tier2_general: ["ssh", "scp", "chmod", "chown", "curl", "wget", "ldapmodify", "ldapdelete", "ldapadd"], + tier3: ["rm", "mkfs", "dd", "fdisk", "kill", "pkill", "killall", "init", "shutdown", "reboot", "halt", "poweroff"], +}; + +describe("Shell Classifier Commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getClassifierRulesCmd", () => { + it("should call invoke with correct command name", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + await tauriCommands.getClassifierRulesCmd(); + + expect(invoke).toHaveBeenCalledWith("get_classifier_rules"); + }); + + it("should return the classifier rules structure", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier1_kubectl).toContain("get"); + expect(result.tier1_kubectl).toContain("logs"); + expect(result.tier2_kubectl).toContain("apply"); + expect(result.tier2_kubectl).toContain("delete"); + expect(result.tier3).toContain("rm"); + expect(result.tier3).toContain("kill"); + expect(result.tier3).toContain("init"); + }); + + it("should include fix for Bug 1 — kill and init in tier3", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier3).toContain("kill"); + expect(result.tier3).toContain("pkill"); + expect(result.tier3).toContain("killall"); + expect(result.tier3).toContain("init"); + }); + + it("should include fix for Bug 2 — systemctl read-only subcommands in tier1", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier1_systemctl).toContain("status"); + expect(result.tier1_systemctl).toContain("is-active"); + expect(result.tier2_systemctl).toContain("restart"); + expect(result.tier2_systemctl).toContain("stop"); + }); + + it("should include fix for Bug 3 — ldap mutating ops in tier2 not tier1", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier2_general).toContain("ldapmodify"); + expect(result.tier2_general).toContain("ldapdelete"); + expect(result.tier2_general).toContain("ldapadd"); + // ldapsearch must NOT appear in tier2 (it's read-only, belongs in tier1) + expect(result.tier2_general).not.toContain("ldapsearch"); + expect(result.tier1_general).toContain("ldapsearch"); + }); + + it("should propagate errors from invoke", async () => { + (invoke as MockedFunction).mockRejectedValue(new Error("IPC error")); + + await expect(tauriCommands.getClassifierRulesCmd()).rejects.toThrow("IPC error"); + }); + }); +}); -- 2.45.2