fix(kube): bridge kubeconfig storage to in-memory cluster map and fix UI issues
Some checks failed
PR Review Automation / review (pull_request) Has been cancelled
Test / frontend-tests (pull_request) Successful in 1m37s
Test / frontend-typecheck (pull_request) Successful in 1m46s
Test / rust-fmt-check (pull_request) Failing after 10m52s
Test / rust-clippy (pull_request) Successful in 12m34s
Test / rust-tests (pull_request) Successful in 14m8s

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.
This commit is contained in:
Shaun Arman 2026-06-07 17:39:07 -05:00
parent 687d9f3466
commit ef3709ffe9
11 changed files with 262 additions and 9 deletions

View File

@ -156,6 +156,44 @@ fn extract_server_url(content: &str) -> Result<String, String> {
.ok_or_else(|| "Server URL not found in cluster".to_string()) .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] #[tauri::command]
pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> { pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> {
// Check existence in memory BEFORE touching the DB // Check existence in memory BEFORE touching the DB

View File

@ -179,6 +179,7 @@ pub fn run() {
commands::shell::check_kubectl_installed, commands::shell::check_kubectl_installed,
// Kubernetes Management // Kubernetes Management
commands::kube::add_cluster, commands::kube::add_cluster,
commands::kube::connect_cluster_from_kubeconfig,
commands::kube::remove_cluster, commands::kube::remove_cluster,
commands::kube::list_clusters, commands::kube::list_clusters,
commands::kube::start_port_forward, commands::kube::start_port_forward,

View File

@ -112,7 +112,9 @@ export function ClusterDetails({ clusterId }: ClusterDetailsProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-semibold">Cluster Details</h2> <h2 className="text-2xl font-semibold">Cluster Details</h2>
<p className="text-muted-foreground text-sm mt-0.5">Cluster ID: {clusterId}</p> <p className="text-muted-foreground text-sm mt-0.5" data-testid="cluster-context-header">
{kubeconfig.context}
</p>
</div> </div>
<button <button
onClick={() => void loadData()} onClick={() => void loadData()}

View File

@ -10,6 +10,7 @@ import type { NodeInfo, PodInfo, DeploymentInfo, NamespaceInfo } from "@/lib/tau
interface ClusterOverviewProps { interface ClusterOverviewProps {
clusterId: string; clusterId: string;
clusterName?: string;
} }
interface SummaryCardProps { interface SummaryCardProps {
@ -42,7 +43,7 @@ function nodeIsReady(node: NodeInfo): boolean {
return node.status === "Ready"; return node.status === "Ready";
} }
export function ClusterOverview({ clusterId }: ClusterOverviewProps) { export function ClusterOverview({ clusterId, clusterName }: ClusterOverviewProps) {
const [nodes, setNodes] = useState<NodeInfo[]>([]); const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [pods, setPods] = useState<PodInfo[]>([]); const [pods, setPods] = useState<PodInfo[]>([]);
const [deployments, setDeployments] = useState<DeploymentInfo[]>([]); const [deployments, setDeployments] = useState<DeploymentInfo[]>([]);
@ -116,7 +117,9 @@ export function ClusterOverview({ clusterId }: ClusterOverviewProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-semibold">Cluster Overview</h2> <h2 className="text-2xl font-semibold">Cluster Overview</h2>
<p className="text-muted-foreground text-sm mt-0.5">Cluster ID: {clusterId}</p> <p className="text-muted-foreground text-sm mt-0.5" data-testid="cluster-name-header">
{clusterName ?? clusterId}
</p>
</div> </div>
<button <button
onClick={() => void loadData()} onClick={() => void loadData()}

View File

@ -9,9 +9,11 @@ interface HotbarProps {
onRefresh: () => void; onRefresh: () => void;
onAddResource: () => void; onAddResource: () => void;
onSettings: () => void; onSettings: () => void;
onNotifications?: () => void;
notificationCount?: number;
} }
export function Hotbar({ onRefresh, onAddResource, onSettings }: HotbarProps) { export function Hotbar({ onRefresh, onAddResource, onSettings, onNotifications, notificationCount = 0 }: HotbarProps) {
const clusters = useStore(useKubernetesStore, (state) => state.clusters); const clusters = useStore(useKubernetesStore, (state) => state.clusters);
const selectedClusterId = useStore(useKubernetesStore, (state) => state.selectedClusterId); const selectedClusterId = useStore(useKubernetesStore, (state) => state.selectedClusterId);
const selectedCluster = clusters.find((c: { id: string }) => c.id === selectedClusterId); const selectedCluster = clusters.find((c: { id: string }) => c.id === selectedClusterId);
@ -38,11 +40,18 @@ export function Hotbar({ onRefresh, onAddResource, onSettings }: HotbarProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="sm"> <Button
variant="ghost"
size="sm"
onClick={onNotifications}
aria-label="Notifications"
>
<Bell className="w-4 h-4" /> <Bell className="w-4 h-4" />
{notificationCount > 0 && (
<Badge variant="destructive" className="h-4 w-4 flex items-center justify-center p-0 text-[10px]"> <Badge variant="destructive" className="h-4 w-4 flex items-center justify-center p-0 text-[10px]">
3 {notificationCount}
</Badge> </Badge>
)}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={onSettings}> <Button variant="ghost" size="sm" onClick={onSettings}>
<Settings className="w-4 h-4" /> <Settings className="w-4 h-4" />

View File

@ -899,6 +899,9 @@ export const addClusterCmd = (id: string, name: string, kubeconfigContent: strin
export const removeClusterCmd = (id: string) => export const removeClusterCmd = (id: string) =>
invoke<void>("remove_cluster", { id }); invoke<void>("remove_cluster", { id });
export const connectClusterFromKubeconfigCmd = (id: string) =>
invoke<void>("connect_cluster_from_kubeconfig", { id });
export const listClustersCmd = () => export const listClustersCmd = () =>
invoke<ClusterInfo[]>("list_clusters"); invoke<ClusterInfo[]>("list_clusters");

View File

@ -18,6 +18,10 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui"; } from "@/components/ui";
import { import {
PodList, PodList,
@ -84,6 +88,7 @@ import type {
import { import {
listKubeconfigsCmd, listKubeconfigsCmd,
activateKubeconfigCmd, activateKubeconfigCmd,
connectClusterFromKubeconfigCmd,
listNamespacesCmd, listNamespacesCmd,
listPortForwardsCmd, listPortForwardsCmd,
startPortForwardCmd, startPortForwardCmd,
@ -299,6 +304,7 @@ export function KubernetesPage() {
const [isLoadingResources, setIsLoadingResources] = useState(false); const [isLoadingResources, setIsLoadingResources] = useState(false);
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false); const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false);
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
// Track the last loaded section to avoid redundant fetches // Track the last loaded section to avoid redundant fetches
const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null); const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null);
@ -316,7 +322,10 @@ export function KubernetesPage() {
const activeConfig = kubeconfigsData.find((c) => c.is_active); const activeConfig = kubeconfigsData.find((c) => c.is_active);
if (activeConfig && !selectedClusterId) { if (activeConfig && !selectedClusterId) {
await connectClusterFromKubeconfigCmd(activeConfig.id).catch(() => {});
setSelectedCluster(activeConfig.id); setSelectedCluster(activeConfig.id);
} else if (selectedClusterId) {
await connectClusterFromKubeconfigCmd(selectedClusterId).catch(() => {});
} }
} catch (err) { } catch (err) {
console.error("Failed to load initial Kubernetes data:", err); console.error("Failed to load initial Kubernetes data:", err);
@ -509,6 +518,7 @@ export function KubernetesPage() {
const handleClusterChange = async (id: string) => { const handleClusterChange = async (id: string) => {
try { try {
await activateKubeconfigCmd(id); await activateKubeconfigCmd(id);
await connectClusterFromKubeconfigCmd(id);
const updated = await listKubeconfigsCmd(); const updated = await listKubeconfigsCmd();
setKubeconfigs(updated); setKubeconfigs(updated);
const active = updated.find((c) => c.is_active); const active = updated.find((c) => c.is_active);
@ -584,7 +594,13 @@ export function KubernetesPage() {
} }
if (activeSection === "overview") { if (activeSection === "overview") {
return <ClusterOverview clusterId={selectedClusterId} />; const overviewConfig = kubeconfigs.find((c) => c.id === selectedClusterId);
return (
<ClusterOverview
clusterId={selectedClusterId}
clusterName={overviewConfig?.context}
/>
);
} }
if (activeSection === "portforwarding") { if (activeSection === "portforwarding") {
@ -689,6 +705,7 @@ export function KubernetesPage() {
onRefresh={handleRefresh} onRefresh={handleRefresh}
onAddResource={() => setIsCommandPaletteOpen(true)} onAddResource={() => setIsCommandPaletteOpen(true)}
onSettings={() => {}} onSettings={() => {}}
onNotifications={() => setIsNotificationsOpen(true)}
/> />
{/* Top bar: cluster selector + namespace selector */} {/* Top bar: cluster selector + namespace selector */}
@ -828,6 +845,33 @@ export function KubernetesPage() {
onNavigate={handleNavigate} onNavigate={handleNavigate}
/> />
{/* Notifications panel */}
<Dialog open={isNotificationsOpen} onOpenChange={setIsNotificationsOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Notifications</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
{selectedConfig ? (
<div className="text-sm">
<p className="font-medium mb-1">Active cluster</p>
<p className="text-muted-foreground">{selectedConfig.context}</p>
{selectedConfig.cluster_url && (
<p className="font-mono text-xs text-muted-foreground mt-0.5 truncate">
{selectedConfig.cluster_url}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">No cluster connected.</p>
)}
<p className="text-xs text-muted-foreground pt-2 border-t">
Navigate to <strong>Cluster Events</strong> to view live cluster events.
</p>
</div>
</DialogContent>
</Dialog>
{/* Port Forward Form (only rendered outside portforwarding section via global trigger) */} {/* Port Forward Form (only rendered outside portforwarding section via global trigger) */}
{activeSection !== "portforwarding" && ( {activeSection !== "portforwarding" && (
<PortForwardForm <PortForwardForm

View File

@ -130,4 +130,19 @@ describe("ClusterDetails", () => {
expect(screen.getByTestId("cluster-no-data")).toBeInTheDocument(); expect(screen.getByTestId("cluster-no-data")).toBeInTheDocument();
}); });
}); });
it("shows context name in header instead of raw GUID", async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === "list_kubeconfigs") return Promise.resolve(mockKubeconfigs);
if (cmd === "list_nodes") return Promise.resolve(mockNodes);
return Promise.resolve([]);
});
render(<ClusterDetails clusterId="cluster-1" />);
await waitFor(() => {
expect(screen.getByTestId("cluster-context-header")).toHaveTextContent("prod-context");
expect(screen.queryByText("cluster-1")).not.toBeInTheDocument();
});
});
}); });

View File

@ -166,4 +166,26 @@ describe("ClusterOverview", () => {
expect(screen.getByTestId("node-ready-status")).toHaveTextContent("Ready: 2/3"); expect(screen.getByTestId("node-ready-status")).toHaveTextContent("Ready: 2/3");
}); });
}); });
it("displays clusterName prop in header instead of raw GUID", async () => {
mockInvoke.mockImplementation(() => Promise.resolve([]));
render(<ClusterOverview clusterId="019e9ff0-b6a4-78e1-a566-7a0c05e32577" clusterName="devops1-mgmt" />);
await waitFor(() => {
expect(screen.getByTestId("cluster-name-header")).toHaveTextContent("devops1-mgmt");
expect(screen.queryByText("019e9ff0-b6a4-78e1-a566-7a0c05e32577")).not.toBeInTheDocument();
});
});
it("falls back gracefully when clusterName prop is not provided", async () => {
mockInvoke.mockImplementation(() => Promise.resolve([]));
render(<ClusterOverview clusterId="cluster-1" />);
await waitFor(() => {
const header = screen.getByTestId("cluster-name-header");
expect(header).toBeInTheDocument();
});
});
}); });

View File

@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Hotbar } from "@/components/Kubernetes/Hotbar";
// Mock zustand's useStore so Hotbar can render without a real store
vi.mock("zustand", () => ({
useStore: vi.fn((_store: unknown, selector: (s: unknown) => unknown) =>
selector({ clusters: [], selectedClusterId: null })
),
}));
vi.mock("@/stores/kubernetesStore", () => ({
useKubernetesStore: vi.fn(),
}));
describe("Hotbar", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without error", () => {
render(
<Hotbar
onRefresh={vi.fn()}
onAddResource={vi.fn()}
onSettings={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: /notification/i })).toBeInTheDocument();
});
it("calls onNotifications when bell icon is clicked", () => {
const onNotifications = vi.fn();
render(
<Hotbar
onRefresh={vi.fn()}
onAddResource={vi.fn()}
onSettings={vi.fn()}
onNotifications={onNotifications}
/>
);
fireEvent.click(screen.getByRole("button", { name: /notification/i }));
expect(onNotifications).toHaveBeenCalledTimes(1);
});
it("renders bell button even without onNotifications prop", () => {
render(
<Hotbar
onRefresh={vi.fn()}
onAddResource={vi.fn()}
onSettings={vi.fn()}
/>
);
const bellButton = screen.getByRole("button", { name: /notification/i });
expect(bellButton).toBeInTheDocument();
expect(() => fireEvent.click(bellButton)).not.toThrow();
});
it("shows notification count badge when notificationCount is provided", () => {
render(
<Hotbar
onRefresh={vi.fn()}
onAddResource={vi.fn()}
onSettings={vi.fn()}
notificationCount={5}
/>
);
expect(screen.getByText("5")).toBeInTheDocument();
});
it("hides badge when notificationCount is zero", () => {
render(
<Hotbar
onRefresh={vi.fn()}
onAddResource={vi.fn()}
onSettings={vi.fn()}
notificationCount={0}
/>
);
expect(screen.queryByText("0")).not.toBeInTheDocument();
});
});

View File

@ -138,4 +138,33 @@ describe("Kubernetes Management Commands", () => {
expect(result[0].pod).toBe("nginx-abc123"); expect(result[0].pod).toBe("nginx-abc123");
}); });
}); });
describe("connectClusterFromKubeconfigCmd", () => {
it("should call invoke with kubeconfig id", async () => {
(invoke as MockedFunction).mockResolvedValue(undefined);
await tauriCommands.connectClusterFromKubeconfigCmd("kubeconfig-uuid-123");
expect(invoke).toHaveBeenCalledWith("connect_cluster_from_kubeconfig", {
id: "kubeconfig-uuid-123",
});
});
it("should resolve with void on success", async () => {
(invoke as MockedFunction).mockResolvedValue(undefined);
const result = await tauriCommands.connectClusterFromKubeconfigCmd("some-id");
expect(result).toBeUndefined();
});
it("should propagate errors from invoke", async () => {
(invoke as MockedFunction).mockRejectedValue(
new Error("Kubeconfig not found")
);
await expect(
tauriCommands.connectClusterFromKubeconfigCmd("bad-id")
).rejects.toThrow("Kubeconfig not found");
});
});
}); });