Compare commits

...

3 Commits

Author SHA1 Message Date
a626f053ed Merge pull request 'fix(kube): bridge kubeconfig storage to in-memory cluster map and fix UI issues' (#79) from fix/kube-cluster-connection into master
Some checks failed
Auto Tag / build-linux-amd64 (push) Successful in 9m19s
Auto Tag / build-windows-amd64 (push) Successful in 11m25s
Auto Tag / build-linux-arm64 (push) Successful in 11m34s
Auto Tag / build-macos-arm64 (push) Failing after 10m46s
Test / rust-fmt-check (push) Successful in 15m53s
Test / rust-clippy (push) Successful in 17m33s
Test / rust-tests (push) Successful in 19m2s
Auto Tag / autotag (push) Successful in 7s
Auto Tag / wiki-sync (push) Successful in 7s
Auto Tag / changelog (push) Successful in 1m26s
Test / frontend-typecheck (push) Successful in 1m49s
Test / frontend-tests (push) Successful in 1m46s
Reviewed-on: #79
2026-06-07 23:32:00 +00:00
Shaun Arman
7d8d5bdbba fix(classifier): fix 3 safety bugs, extract const arrays, make tier UI dynamic
All checks were successful
Test / frontend-typecheck (pull_request) Successful in 1m36s
Test / frontend-tests (pull_request) Successful in 1m40s
PR Review Automation / review (pull_request) Successful in 10m27s
Test / rust-fmt-check (pull_request) Successful in 11m4s
Test / rust-clippy (pull_request) Successful in 12m50s
Test / rust-tests (pull_request) Successful in 14m20s
Bug 1 — Dead multi-word tier3 entries / missing single-token commands
  parse_single_command() extracts only the first token as `command`, so
  multi-word entries like "kill -9", "init 0", "service stop" in the tier3
  array never matched. Adding the single-token forms "kill", "pkill",
  "killall", "init" to TIER3_COMMANDS ensures these commands are always
  denied. Removed all dead multi-word entries.

Bug 2 — systemctl Tier 1 special case was dead code
  systemctl was not in tier1_general, so the block that was supposed to
  auto-execute `systemctl status` never ran. Moved systemctl handling into
  its own block (TIER1_SYSTEMCTL_SUBCOMMANDS / TIER2_SYSTEMCTL_SUBCOMMANDS)
  evaluated before the general tier checks. status, is-active, is-enabled,
  list-units, list-unit-files → Tier 1; all others → Tier 2.

Bug 3 — ldapmodify / ldapdelete / ldapadd misclassified as Tier 1
  Both appeared in the old tier1_general and tier2_general arrays; the tier1
  check ran first, so LDAP write operations auto-executed. Removed them from
  tier1. ldapsearch (read-only) remains Tier 1.

Dynamic Safety Architecture UI
  Extracted all tier classification arrays to module-level pub const slices
  (TIER3_COMMANDS, TIER1_KUBECTL_SUBCOMMANDS, etc.) so both the classifier
  logic and a new get_classifier_rules() Tauri command share a single source
  of truth. ShellExecution.tsx now calls getClassifierRulesCmd() on mount and
  renders the actual command lists in collapsible per-tier cards — any change
  to the const arrays is automatically reflected in the UI with no manual
  documentation update needed.

Also fixes the cargo fmt CI failure introduced in the previous commit
(ClusterClient::new call reformatted to a single line).
2026-06-07 18:15:42 -05:00
Shaun Arman
ef3709ffe9 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.
2026-06-07 17:39:07 -05: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" />
<Badge variant="destructive" className="h-4 w-4 flex items-center justify-center p-0 text-[10px]">
3
</Badge>
{notificationCount > 0 && (
<Badge variant="destructive" className="h-4 w-4 flex items-center justify-center p-0 text-[10px]">
{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");
});
});
});