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
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
This commit is contained in:
commit
a626f053ed
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
@ -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" />
|
||||
<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" />
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
93
tests/unit/shellCommands.test.ts
Normal file
93
tests/unit/shellCommands.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user