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
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:
parent
687d9f3466
commit
ef3709ffe9
@ -156,6 +156,44 @@ fn extract_server_url(content: &str) -> Result<String, 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]
|
||||
pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
// Check existence in memory BEFORE touching the DB
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -112,7 +112,9 @@ export function ClusterDetails({ clusterId }: ClusterDetailsProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<button
|
||||
onClick={() => void loadData()}
|
||||
|
||||
@ -10,6 +10,7 @@ import type { NodeInfo, PodInfo, DeploymentInfo, NamespaceInfo } from "@/lib/tau
|
||||
|
||||
interface ClusterOverviewProps {
|
||||
clusterId: string;
|
||||
clusterName?: string;
|
||||
}
|
||||
|
||||
interface SummaryCardProps {
|
||||
@ -42,7 +43,7 @@ function nodeIsReady(node: NodeInfo): boolean {
|
||||
return node.status === "Ready";
|
||||
}
|
||||
|
||||
export function ClusterOverview({ clusterId }: ClusterOverviewProps) {
|
||||
export function ClusterOverview({ clusterId, clusterName }: ClusterOverviewProps) {
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [pods, setPods] = useState<PodInfo[]>([]);
|
||||
const [deployments, setDeployments] = useState<DeploymentInfo[]>([]);
|
||||
@ -116,7 +117,9 @@ export function ClusterOverview({ clusterId }: ClusterOverviewProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<button
|
||||
onClick={() => void loadData()}
|
||||
|
||||
@ -9,9 +9,11 @@ interface HotbarProps {
|
||||
onRefresh: () => void;
|
||||
onAddResource: () => 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 selectedClusterId = useStore(useKubernetesStore, (state) => state.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">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onNotifications}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<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]">
|
||||
3
|
||||
{notificationCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onSettings}>
|
||||
<Settings className="w-4 h-4" />
|
||||
|
||||
@ -899,6 +899,9 @@ export const addClusterCmd = (id: string, name: string, kubeconfigContent: strin
|
||||
export const removeClusterCmd = (id: string) =>
|
||||
invoke<void>("remove_cluster", { id });
|
||||
|
||||
export const connectClusterFromKubeconfigCmd = (id: string) =>
|
||||
invoke<void>("connect_cluster_from_kubeconfig", { id });
|
||||
|
||||
export const listClustersCmd = () =>
|
||||
invoke<ClusterInfo[]>("list_clusters");
|
||||
|
||||
|
||||
@ -18,6 +18,10 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui";
|
||||
import {
|
||||
PodList,
|
||||
@ -84,6 +88,7 @@ import type {
|
||||
import {
|
||||
listKubeconfigsCmd,
|
||||
activateKubeconfigCmd,
|
||||
connectClusterFromKubeconfigCmd,
|
||||
listNamespacesCmd,
|
||||
listPortForwardsCmd,
|
||||
startPortForwardCmd,
|
||||
@ -299,6 +304,7 @@ export function KubernetesPage() {
|
||||
const [isLoadingResources, setIsLoadingResources] = useState(false);
|
||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||
const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false);
|
||||
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
|
||||
|
||||
// Track the last loaded section to avoid redundant fetches
|
||||
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);
|
||||
if (activeConfig && !selectedClusterId) {
|
||||
await connectClusterFromKubeconfigCmd(activeConfig.id).catch(() => {});
|
||||
setSelectedCluster(activeConfig.id);
|
||||
} else if (selectedClusterId) {
|
||||
await connectClusterFromKubeconfigCmd(selectedClusterId).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load initial Kubernetes data:", err);
|
||||
@ -509,6 +518,7 @@ export function KubernetesPage() {
|
||||
const handleClusterChange = async (id: string) => {
|
||||
try {
|
||||
await activateKubeconfigCmd(id);
|
||||
await connectClusterFromKubeconfigCmd(id);
|
||||
const updated = await listKubeconfigsCmd();
|
||||
setKubeconfigs(updated);
|
||||
const active = updated.find((c) => c.is_active);
|
||||
@ -584,7 +594,13 @@ export function KubernetesPage() {
|
||||
}
|
||||
|
||||
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") {
|
||||
@ -689,6 +705,7 @@ export function KubernetesPage() {
|
||||
onRefresh={handleRefresh}
|
||||
onAddResource={() => setIsCommandPaletteOpen(true)}
|
||||
onSettings={() => {}}
|
||||
onNotifications={() => setIsNotificationsOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Top bar: cluster selector + namespace selector */}
|
||||
@ -828,6 +845,33 @@ export function KubernetesPage() {
|
||||
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) */}
|
||||
{activeSection !== "portforwarding" && (
|
||||
<PortForwardForm
|
||||
|
||||
@ -130,4 +130,19 @@ describe("ClusterDetails", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -166,4 +166,26 @@ describe("ClusterOverview", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
87
tests/unit/Hotbar.test.tsx
Normal file
87
tests/unit/Hotbar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -138,4 +138,33 @@ describe("Kubernetes Management Commands", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user