fix(kube): bridge kubeconfig storage to in-memory cluster map and fix UI issues #79

Merged
sarman merged 2 commits from fix/kube-cluster-connection into master 2026-06-07 23:32:01 +00:00
15 changed files with 1181 additions and 768 deletions

View File

@ -156,6 +156,38 @@ 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

View File

@ -232,3 +232,11 @@ pub async fn check_kubectl_installed(_state: State<'_, AppState>) -> Result<Kube
}),
}
}
/// Return the live classifier rule lists so the UI can render them dynamically.
/// The data derives directly from the module-level const arrays in classifier.rs,
/// so any addition or removal there is automatically reflected in the UI.
#[tauri::command]
pub fn get_classifier_rules() -> crate::shell::classifier::ClassifierRules {
crate::shell::classifier::CommandClassifier::get_rules()
}

View File

@ -177,8 +177,10 @@ pub fn run() {
commands::shell::respond_to_shell_approval,
commands::shell::list_command_executions,
commands::shell::check_kubectl_installed,
commands::shell::get_classifier_rules,
// 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,

File diff suppressed because it is too large Load Diff

View File

@ -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()}

View File

@ -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()}

View File

@ -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" />

View File

@ -739,6 +739,21 @@ export const listCommandExecutionsCmd = (issueId?: string) =>
export const checkKubectlInstalledCmd = () =>
invoke<KubectlStatus>("check_kubectl_installed");
export interface ClassifierRules {
tier1_kubectl: string[];
tier1_systemctl: string[];
tier1_proxmox: string[];
tier1_general: string[];
tier2_kubectl: string[];
tier2_systemctl: string[];
tier2_proxmox: string[];
tier2_general: string[];
tier3: string[];
}
export const getClassifierRulesCmd = () =>
invoke<ClassifierRules>("get_classifier_rules");
// ─── Kubernetes Management Types ──────────────────────────────────────────────
export interface ClusterInfo {
@ -899,6 +914,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");

View File

@ -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

View File

@ -1,17 +1,165 @@
import { useState, useEffect } from 'react';
import { Terminal, CheckCircle, XCircle, Shield, History } from 'lucide-react';
import { Terminal, CheckCircle, XCircle, Shield, History, ChevronDown } from 'lucide-react';
import { Button, Card, CardHeader, CardTitle, CardContent, Badge } from '@/components/ui';
import { Link } from 'react-router-dom';
import {
checkKubectlInstalledCmd,
listCommandExecutionsCmd,
getClassifierRulesCmd,
type KubectlStatus,
type CommandExecution,
type ClassifierRules,
} from '@/lib/tauriCommands';
// ── Tier display config ───────────────────────────────────────────────────────
interface TierConfig {
label: string;
behavior: string;
colorBg: string;
colorBorder: string;
colorHeading: string;
colorText: string;
badgeClass: string;
tier: 1 | 2 | 3;
}
const TIER_CONFIG: TierConfig[] = [
{
tier: 1,
label: 'Tier 1',
behavior: 'Auto-execute (Read-only)',
colorBg: 'bg-green-50',
colorBorder: 'border-green-200',
colorHeading: 'text-green-900',
colorText: 'text-green-800',
badgeClass: 'bg-green-100 text-green-700 border-green-300',
},
{
tier: 2,
label: 'Tier 2',
behavior: 'Require approval (Mutating)',
colorBg: 'bg-yellow-50',
colorBorder: 'border-yellow-200',
colorHeading: 'text-yellow-900',
colorText: 'text-yellow-800',
badgeClass: 'bg-yellow-100 text-yellow-700 border-yellow-300',
},
{
tier: 3,
label: 'Tier 3',
behavior: 'Always deny (Destructive)',
colorBg: 'bg-red-50',
colorBorder: 'border-red-200',
colorHeading: 'text-red-900',
colorText: 'text-red-800',
badgeClass: 'bg-red-100 text-red-700 border-red-300',
},
];
// ── Helper: build per-tier category groups from ClassifierRules ───────────────
interface CategoryGroup {
label: string;
commands: string[];
}
function buildTier1Groups(rules: ClassifierRules): CategoryGroup[] {
return [
{ label: 'kubectl', commands: rules.tier1_kubectl.map((c) => `kubectl ${c}`) },
{ label: 'systemctl', commands: rules.tier1_systemctl.map((c) => `systemctl ${c}`) },
{ label: 'proxmox', commands: rules.tier1_proxmox.map((c) => `<cmd> ${c}`) },
{ label: 'general', commands: rules.tier1_general },
].filter((g) => g.commands.length > 0);
}
function buildTier2Groups(rules: ClassifierRules): CategoryGroup[] {
return [
{ label: 'kubectl', commands: rules.tier2_kubectl.map((c) => `kubectl ${c}`) },
{ label: 'systemctl', commands: rules.tier2_systemctl.map((c) => `systemctl ${c}`) },
{ label: 'proxmox', commands: rules.tier2_proxmox.map((c) => `<cmd> ${c}`) },
{ label: 'general', commands: rules.tier2_general },
].filter((g) => g.commands.length > 0);
}
function buildTier3Groups(rules: ClassifierRules): CategoryGroup[] {
return [{ label: 'all', commands: rules.tier3 }];
}
const PREVIEW_COUNT = 6;
// ── Sub-components ────────────────────────────────────────────────────────────
function CommandChip({ cmd, colorText }: { cmd: string; colorText: string }) {
return (
<code
className={`inline-block rounded px-1.5 py-0.5 text-xs font-mono border border-current/20 ${colorText}`}
>
{cmd}
</code>
);
}
interface TierCardProps {
config: TierConfig;
groups: CategoryGroup[];
}
function TierCard({ config, groups }: TierCardProps) {
const [expanded, setExpanded] = useState(false);
const allCommands = groups.flatMap((g) => g.commands);
const total = allCommands.length;
const previewCommands = allCommands.slice(0, PREVIEW_COUNT);
const hasMore = total > PREVIEW_COUNT;
return (
<div
className={`rounded-lg p-3 border ${config.colorBg} ${config.colorBorder}`}
data-testid={`tier${config.tier}-card`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 min-w-0">
<Badge className={config.badgeClass}>{config.label}</Badge>
<div className="min-w-0">
<div className={`font-medium ${config.colorHeading}`}>{config.behavior}</div>
<div
className={`mt-1.5 flex flex-wrap gap-1 ${config.colorText}`}
data-testid={`tier${config.tier}-commands`}
>
{(expanded ? allCommands : previewCommands).map((cmd) => (
<CommandChip key={cmd} cmd={cmd} colorText={config.colorText} />
))}
</div>
</div>
</div>
<span className={`shrink-0 text-xs font-mono tabular-nums ${config.colorText} opacity-70`}>
{total}
</span>
</div>
{hasMore && (
<button
onClick={() => setExpanded((p) => !p)}
className={`mt-2 flex items-center gap-1 text-xs ${config.colorText} hover:opacity-80 transition-opacity`}
data-testid={`tier${config.tier}-toggle`}
>
<ChevronDown
className={`h-3 w-3 transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
{expanded ? 'Show fewer' : `Show all ${total} commands`}
</button>
)}
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export default function ShellExecution() {
const [kubectlStatus, setKubectlStatus] = useState<KubectlStatus | null>(null);
const [executions, setExecutions] = useState<CommandExecution[]>([]);
const [classifierRules, setClassifierRules] = useState<ClassifierRules | null>(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<number, string> = {
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<string, { label: string; color: string }> = {
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 (
<div className="p-6 space-y-6">
<div>
@ -126,7 +288,7 @@ export default function ShellExecution() {
</CardContent>
</Card>
{/* Safety Architecture */}
{/* Safety Architecture — driven by live classifier data */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@ -134,42 +296,21 @@ export default function ShellExecution() {
Safety Architecture
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
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.
</p>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-50 border border-green-200">
<Badge className={getTierBadge(1)}>Tier 1</Badge>
<div className="space-y-1">
<div className="font-medium text-green-900">Auto-execute (Read-only)</div>
<div className="text-sm text-green-800">
kubectl get, describe, logs | cat, grep, ls
</div>
</div>
</div>
{!classifierRules && (
<p className="text-xs text-muted-foreground">Loading classifier rules</p>
)}
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-50 border border-yellow-200">
<Badge className={getTierBadge(2)}>Tier 2</Badge>
<div className="space-y-1">
<div className="font-medium text-yellow-900">Require approval (Mutating)</div>
<div className="text-sm text-yellow-800">
kubectl apply, delete, scale | ssh, chmod, systemctl restart
</div>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-50 border border-red-200">
<Badge className={getTierBadge(3)}>Tier 3</Badge>
<div className="space-y-1">
<div className="font-medium text-red-900">Always deny (Destructive)</div>
<div className="text-sm text-red-800">
rm -rf, shutdown, mkfs, dd
</div>
</div>
</div>
</div>
{TIER_CONFIG.map((cfg) => {
const groups =
cfg.tier === 1 ? tier1Groups : cfg.tier === 2 ? tier2Groups : tier3Groups;
return <TierCard key={cfg.tier} config={cfg} groups={groups} />;
})}
</CardContent>
</Card>
@ -201,12 +342,8 @@ export default function ShellExecution() {
</code>
</div>
<div className="flex gap-2 ml-3 flex-shrink-0">
<Badge className={getTierBadge(exec.tier)}>
T{exec.tier}
</Badge>
<Badge className={statusConfig.color}>
{statusConfig.label}
</Badge>
<Badge className={getTierBadge(exec.tier)}>T{exec.tier}</Badge>
<Badge className={statusConfig.color}>{statusConfig.label}</Badge>
</div>
</div>

View File

@ -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();
});
});
});

View File

@ -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();
});
});
});

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");
});
});
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");
});
});
});

View File

@ -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<T = (...args: unknown[]) => 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");
});
});
});