fix(kube): bridge kubeconfig storage to in-memory cluster map and fix UI issues #79
@ -156,6 +156,38 @@ fn extract_server_url(content: &str) -> Result<String, String> {
|
|||||||
.ok_or_else(|| "Server URL not found in cluster".to_string())
|
.ok_or_else(|| "Server URL not found in cluster".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load a stored kubeconfig into the in-memory cluster map so all kube commands can use it.
|
||||||
|
///
|
||||||
|
/// This bridges the kubeconfig_files table (encrypted storage) with the in-memory
|
||||||
|
/// state.clusters map that every kubernetes command requires.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn connect_cluster_from_kubeconfig(
|
||||||
|
id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Read name and encrypted content from DB
|
||||||
|
let (name, encrypted_content) = {
|
||||||
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
db.query_row(
|
||||||
|
"SELECT name, encrypted_content FROM kubeconfig_files WHERE id = ?1",
|
||||||
|
rusqlite::params![&id],
|
||||||
|
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Kubeconfig {id} not found in storage: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = crate::integrations::auth::decrypt_token(&encrypted_content)?;
|
||||||
|
let context = extract_context(&content)?;
|
||||||
|
let server_url = extract_server_url(&content).unwrap_or_default();
|
||||||
|
|
||||||
|
let client = ClusterClient::new(id.clone(), name, context, server_url, Arc::new(content));
|
||||||
|
|
||||||
|
let mut clusters = state.clusters.lock().await;
|
||||||
|
clusters.insert(id, client);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> {
|
pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
// Check existence in memory BEFORE touching the DB
|
// Check existence in memory BEFORE touching the DB
|
||||||
|
|||||||
@ -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::respond_to_shell_approval,
|
||||||
commands::shell::list_command_executions,
|
commands::shell::list_command_executions,
|
||||||
commands::shell::check_kubectl_installed,
|
commands::shell::check_kubectl_installed,
|
||||||
|
commands::shell::get_classifier_rules,
|
||||||
// Kubernetes Management
|
// Kubernetes Management
|
||||||
commands::kube::add_cluster,
|
commands::kube::add_cluster,
|
||||||
|
commands::kube::connect_cluster_from_kubeconfig,
|
||||||
commands::kube::remove_cluster,
|
commands::kube::remove_cluster,
|
||||||
commands::kube::list_clusters,
|
commands::kube::list_clusters,
|
||||||
commands::kube::start_port_forward,
|
commands::kube::start_port_forward,
|
||||||
|
|||||||
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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold">Cluster Details</h2>
|
<h2 className="text-2xl font-semibold">Cluster Details</h2>
|
||||||
<p className="text-muted-foreground text-sm mt-0.5">Cluster ID: {clusterId}</p>
|
<p className="text-muted-foreground text-sm mt-0.5" data-testid="cluster-context-header">
|
||||||
|
{kubeconfig.context}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => void loadData()}
|
onClick={() => void loadData()}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { NodeInfo, PodInfo, DeploymentInfo, NamespaceInfo } from "@/lib/tau
|
|||||||
|
|
||||||
interface ClusterOverviewProps {
|
interface ClusterOverviewProps {
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
|
clusterName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SummaryCardProps {
|
interface SummaryCardProps {
|
||||||
@ -42,7 +43,7 @@ function nodeIsReady(node: NodeInfo): boolean {
|
|||||||
return node.status === "Ready";
|
return node.status === "Ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClusterOverview({ clusterId }: ClusterOverviewProps) {
|
export function ClusterOverview({ clusterId, clusterName }: ClusterOverviewProps) {
|
||||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||||
const [pods, setPods] = useState<PodInfo[]>([]);
|
const [pods, setPods] = useState<PodInfo[]>([]);
|
||||||
const [deployments, setDeployments] = useState<DeploymentInfo[]>([]);
|
const [deployments, setDeployments] = useState<DeploymentInfo[]>([]);
|
||||||
@ -116,7 +117,9 @@ export function ClusterOverview({ clusterId }: ClusterOverviewProps) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold">Cluster Overview</h2>
|
<h2 className="text-2xl font-semibold">Cluster Overview</h2>
|
||||||
<p className="text-muted-foreground text-sm mt-0.5">Cluster ID: {clusterId}</p>
|
<p className="text-muted-foreground text-sm mt-0.5" data-testid="cluster-name-header">
|
||||||
|
{clusterName ?? clusterId}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => void loadData()}
|
onClick={() => void loadData()}
|
||||||
|
|||||||
@ -9,9 +9,11 @@ interface HotbarProps {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onAddResource: () => void;
|
onAddResource: () => void;
|
||||||
onSettings: () => void;
|
onSettings: () => void;
|
||||||
|
onNotifications?: () => void;
|
||||||
|
notificationCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Hotbar({ onRefresh, onAddResource, onSettings }: HotbarProps) {
|
export function Hotbar({ onRefresh, onAddResource, onSettings, onNotifications, notificationCount = 0 }: HotbarProps) {
|
||||||
const clusters = useStore(useKubernetesStore, (state) => state.clusters);
|
const clusters = useStore(useKubernetesStore, (state) => state.clusters);
|
||||||
const selectedClusterId = useStore(useKubernetesStore, (state) => state.selectedClusterId);
|
const selectedClusterId = useStore(useKubernetesStore, (state) => state.selectedClusterId);
|
||||||
const selectedCluster = clusters.find((c: { id: string }) => c.id === selectedClusterId);
|
const selectedCluster = clusters.find((c: { id: string }) => c.id === selectedClusterId);
|
||||||
@ -38,11 +40,18 @@ export function Hotbar({ onRefresh, onAddResource, onSettings }: HotbarProps) {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onNotifications}
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
<Bell className="w-4 h-4" />
|
<Bell className="w-4 h-4" />
|
||||||
<Badge variant="destructive" className="h-4 w-4 flex items-center justify-center p-0 text-[10px]">
|
{notificationCount > 0 && (
|
||||||
3
|
<Badge variant="destructive" className="h-4 w-4 flex items-center justify-center p-0 text-[10px]">
|
||||||
</Badge>
|
{notificationCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={onSettings}>
|
<Button variant="ghost" size="sm" onClick={onSettings}>
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
|
|||||||
@ -739,6 +739,21 @@ export const listCommandExecutionsCmd = (issueId?: string) =>
|
|||||||
export const checkKubectlInstalledCmd = () =>
|
export const checkKubectlInstalledCmd = () =>
|
||||||
invoke<KubectlStatus>("check_kubectl_installed");
|
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 ──────────────────────────────────────────────
|
// ─── Kubernetes Management Types ──────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ClusterInfo {
|
export interface ClusterInfo {
|
||||||
@ -899,6 +914,9 @@ export const addClusterCmd = (id: string, name: string, kubeconfigContent: strin
|
|||||||
export const removeClusterCmd = (id: string) =>
|
export const removeClusterCmd = (id: string) =>
|
||||||
invoke<void>("remove_cluster", { id });
|
invoke<void>("remove_cluster", { id });
|
||||||
|
|
||||||
|
export const connectClusterFromKubeconfigCmd = (id: string) =>
|
||||||
|
invoke<void>("connect_cluster_from_kubeconfig", { id });
|
||||||
|
|
||||||
export const listClustersCmd = () =>
|
export const listClustersCmd = () =>
|
||||||
invoke<ClusterInfo[]>("list_clusters");
|
invoke<ClusterInfo[]>("list_clusters");
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,10 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import {
|
import {
|
||||||
PodList,
|
PodList,
|
||||||
@ -84,6 +88,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
listKubeconfigsCmd,
|
listKubeconfigsCmd,
|
||||||
activateKubeconfigCmd,
|
activateKubeconfigCmd,
|
||||||
|
connectClusterFromKubeconfigCmd,
|
||||||
listNamespacesCmd,
|
listNamespacesCmd,
|
||||||
listPortForwardsCmd,
|
listPortForwardsCmd,
|
||||||
startPortForwardCmd,
|
startPortForwardCmd,
|
||||||
@ -299,6 +304,7 @@ export function KubernetesPage() {
|
|||||||
const [isLoadingResources, setIsLoadingResources] = useState(false);
|
const [isLoadingResources, setIsLoadingResources] = useState(false);
|
||||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||||
const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false);
|
const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false);
|
||||||
|
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
|
||||||
|
|
||||||
// Track the last loaded section to avoid redundant fetches
|
// Track the last loaded section to avoid redundant fetches
|
||||||
const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null);
|
const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null);
|
||||||
@ -316,7 +322,10 @@ export function KubernetesPage() {
|
|||||||
|
|
||||||
const activeConfig = kubeconfigsData.find((c) => c.is_active);
|
const activeConfig = kubeconfigsData.find((c) => c.is_active);
|
||||||
if (activeConfig && !selectedClusterId) {
|
if (activeConfig && !selectedClusterId) {
|
||||||
|
await connectClusterFromKubeconfigCmd(activeConfig.id).catch(() => {});
|
||||||
setSelectedCluster(activeConfig.id);
|
setSelectedCluster(activeConfig.id);
|
||||||
|
} else if (selectedClusterId) {
|
||||||
|
await connectClusterFromKubeconfigCmd(selectedClusterId).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load initial Kubernetes data:", err);
|
console.error("Failed to load initial Kubernetes data:", err);
|
||||||
@ -509,6 +518,7 @@ export function KubernetesPage() {
|
|||||||
const handleClusterChange = async (id: string) => {
|
const handleClusterChange = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await activateKubeconfigCmd(id);
|
await activateKubeconfigCmd(id);
|
||||||
|
await connectClusterFromKubeconfigCmd(id);
|
||||||
const updated = await listKubeconfigsCmd();
|
const updated = await listKubeconfigsCmd();
|
||||||
setKubeconfigs(updated);
|
setKubeconfigs(updated);
|
||||||
const active = updated.find((c) => c.is_active);
|
const active = updated.find((c) => c.is_active);
|
||||||
@ -584,7 +594,13 @@ export function KubernetesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection === "overview") {
|
if (activeSection === "overview") {
|
||||||
return <ClusterOverview clusterId={selectedClusterId} />;
|
const overviewConfig = kubeconfigs.find((c) => c.id === selectedClusterId);
|
||||||
|
return (
|
||||||
|
<ClusterOverview
|
||||||
|
clusterId={selectedClusterId}
|
||||||
|
clusterName={overviewConfig?.context}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection === "portforwarding") {
|
if (activeSection === "portforwarding") {
|
||||||
@ -689,6 +705,7 @@ export function KubernetesPage() {
|
|||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onAddResource={() => setIsCommandPaletteOpen(true)}
|
onAddResource={() => setIsCommandPaletteOpen(true)}
|
||||||
onSettings={() => {}}
|
onSettings={() => {}}
|
||||||
|
onNotifications={() => setIsNotificationsOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Top bar: cluster selector + namespace selector */}
|
{/* Top bar: cluster selector + namespace selector */}
|
||||||
@ -828,6 +845,33 @@ export function KubernetesPage() {
|
|||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Notifications panel */}
|
||||||
|
<Dialog open={isNotificationsOpen} onOpenChange={setIsNotificationsOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Notifications</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
{selectedConfig ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium mb-1">Active cluster</p>
|
||||||
|
<p className="text-muted-foreground">{selectedConfig.context}</p>
|
||||||
|
{selectedConfig.cluster_url && (
|
||||||
|
<p className="font-mono text-xs text-muted-foreground mt-0.5 truncate">
|
||||||
|
{selectedConfig.cluster_url}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No cluster connected.</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground pt-2 border-t">
|
||||||
|
Navigate to <strong>Cluster → Events</strong> to view live cluster events.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Port Forward Form (only rendered outside portforwarding section via global trigger) */}
|
{/* Port Forward Form (only rendered outside portforwarding section via global trigger) */}
|
||||||
{activeSection !== "portforwarding" && (
|
{activeSection !== "portforwarding" && (
|
||||||
<PortForwardForm
|
<PortForwardForm
|
||||||
|
|||||||
@ -1,17 +1,165 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 { Button, Card, CardHeader, CardTitle, CardContent, Badge } from '@/components/ui';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
checkKubectlInstalledCmd,
|
checkKubectlInstalledCmd,
|
||||||
listCommandExecutionsCmd,
|
listCommandExecutionsCmd,
|
||||||
|
getClassifierRulesCmd,
|
||||||
type KubectlStatus,
|
type KubectlStatus,
|
||||||
type CommandExecution,
|
type CommandExecution,
|
||||||
|
type ClassifierRules,
|
||||||
} from '@/lib/tauriCommands';
|
} 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() {
|
export default function ShellExecution() {
|
||||||
const [kubectlStatus, setKubectlStatus] = useState<KubectlStatus | null>(null);
|
const [kubectlStatus, setKubectlStatus] = useState<KubectlStatus | null>(null);
|
||||||
const [executions, setExecutions] = useState<CommandExecution[]>([]);
|
const [executions, setExecutions] = useState<CommandExecution[]>([]);
|
||||||
|
const [classifierRules, setClassifierRules] = useState<ClassifierRules | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
loadKubectlStatus();
|
loadKubectlStatus();
|
||||||
loadExecutions();
|
loadExecutions();
|
||||||
|
loadClassifierRules();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getTierBadge = (tier: number) => {
|
const getTierBadge = (tier: number) => {
|
||||||
const colors = {
|
const colors: Record<number, string> = {
|
||||||
1: 'bg-green-100 text-green-700 border-green-300',
|
1: 'bg-green-100 text-green-700 border-green-300',
|
||||||
2: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
2: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||||
3: 'bg-red-100 text-red-700 border-red-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 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' },
|
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' },
|
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' },
|
denied: { label: 'Denied', color: 'bg-red-100 text-red-700 border-red-300' },
|
||||||
};
|
};
|
||||||
const statusConfig = config[status as keyof typeof config] || config.auto;
|
return config[status] ?? config.auto;
|
||||||
return statusConfig;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@ -126,7 +288,7 @@ export default function ShellExecution() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Safety Architecture */}
|
{/* Safety Architecture — driven by live classifier data */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@ -134,42 +296,21 @@ export default function ShellExecution() {
|
|||||||
Safety Architecture
|
Safety Architecture
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{!classifierRules && (
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-50 border border-green-200">
|
<p className="text-xs text-muted-foreground">Loading classifier rules…</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-50 border border-yellow-200">
|
{TIER_CONFIG.map((cfg) => {
|
||||||
<Badge className={getTierBadge(2)}>Tier 2</Badge>
|
const groups =
|
||||||
<div className="space-y-1">
|
cfg.tier === 1 ? tier1Groups : cfg.tier === 2 ? tier2Groups : tier3Groups;
|
||||||
<div className="font-medium text-yellow-900">Require approval (Mutating)</div>
|
return <TierCard key={cfg.tier} config={cfg} groups={groups} />;
|
||||||
<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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -201,12 +342,8 @@ export default function ShellExecution() {
|
|||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 ml-3 flex-shrink-0">
|
<div className="flex gap-2 ml-3 flex-shrink-0">
|
||||||
<Badge className={getTierBadge(exec.tier)}>
|
<Badge className={getTierBadge(exec.tier)}>T{exec.tier}</Badge>
|
||||||
T{exec.tier}
|
<Badge className={statusConfig.color}>{statusConfig.label}</Badge>
|
||||||
</Badge>
|
|
||||||
<Badge className={statusConfig.color}>
|
|
||||||
{statusConfig.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -130,4 +130,19 @@ describe("ClusterDetails", () => {
|
|||||||
expect(screen.getByTestId("cluster-no-data")).toBeInTheDocument();
|
expect(screen.getByTestId("cluster-no-data")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows context name in header instead of raw GUID", async () => {
|
||||||
|
mockInvoke.mockImplementation((cmd: string) => {
|
||||||
|
if (cmd === "list_kubeconfigs") return Promise.resolve(mockKubeconfigs);
|
||||||
|
if (cmd === "list_nodes") return Promise.resolve(mockNodes);
|
||||||
|
return Promise.resolve([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ClusterDetails clusterId="cluster-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("cluster-context-header")).toHaveTextContent("prod-context");
|
||||||
|
expect(screen.queryByText("cluster-1")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -166,4 +166,26 @@ describe("ClusterOverview", () => {
|
|||||||
expect(screen.getByTestId("node-ready-status")).toHaveTextContent("Ready: 2/3");
|
expect(screen.getByTestId("node-ready-status")).toHaveTextContent("Ready: 2/3");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("displays clusterName prop in header instead of raw GUID", async () => {
|
||||||
|
mockInvoke.mockImplementation(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
render(<ClusterOverview clusterId="019e9ff0-b6a4-78e1-a566-7a0c05e32577" clusterName="devops1-mgmt" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("cluster-name-header")).toHaveTextContent("devops1-mgmt");
|
||||||
|
expect(screen.queryByText("019e9ff0-b6a4-78e1-a566-7a0c05e32577")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back gracefully when clusterName prop is not provided", async () => {
|
||||||
|
mockInvoke.mockImplementation(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
render(<ClusterOverview clusterId="cluster-1" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const header = screen.getByTestId("cluster-name-header");
|
||||||
|
expect(header).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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");
|
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