feat: implement full Lens-like Kubernetes UI with resource discovery and management
Some checks failed
Test / frontend-tests (pull_request) Successful in 1m33s
Test / frontend-typecheck (pull_request) Successful in 1m42s
PR Review Automation / review (pull_request) Successful in 4m28s
Test / rust-fmt-check (pull_request) Failing after 11m26s
Test / rust-clippy (pull_request) Successful in 12m46s
Test / rust-tests (pull_request) Successful in 14m24s

- Add ResourceBrowser with namespace/resource type tabs for pods, services, deployments, statefulsets, daemonsets
- Implement PodList with logs viewer and container selection
- Implement ServiceList with cluster IP, type, ports display
- Implement DeploymentList with scale and restart operations
- Add backend commands: list_namespaces, list_pods, list_services, list_deployments, list_statefulsets, list_daemonsets
- Add resource management commands: get_pod_logs, scale_deployment, restart_deployment, delete_resource, exec_pod
- Add UI components: Table, Tabs, Dialog, Alert to shared UI library
- Update KubernetesPage to use new ResourceBrowser component
- All tests passing (331 Rust + 98 frontend)
- Build successful in release mode
This commit is contained in:
Shaun Arman 2026-06-06 23:08:01 -05:00
parent 3833d604f7
commit e585415598
12 changed files with 2197 additions and 84 deletions

File diff suppressed because it is too large Load Diff

View File

@ -187,6 +187,19 @@ pub fn run() {
commands::kube::shutdown_port_forwards, commands::kube::shutdown_port_forwards,
commands::kube::test_cluster_connection, commands::kube::test_cluster_connection,
commands::kube::discover_pods, commands::kube::discover_pods,
// Kubernetes Resource Discovery
commands::kube::list_namespaces,
commands::kube::list_pods,
commands::kube::list_services,
commands::kube::list_deployments,
commands::kube::list_statefulsets,
commands::kube::list_daemonsets,
// Kubernetes Resource Management
commands::kube::get_pod_logs,
commands::kube::scale_deployment,
commands::kube::restart_deployment,
commands::kube::delete_resource,
commands::kube::exec_pod,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("Error running Troubleshooting and RCA Assistant application"); .expect("Error running Troubleshooting and RCA Assistant application");

View File

@ -15,6 +15,7 @@ import {
Moon, Moon,
Terminal, Terminal,
FileCode, FileCode,
Server,
} from "lucide-react"; } from "lucide-react";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
import { getAppVersionCmd, loadAiProvidersCmd, testProviderConnectionCmd, shutdownPortForwardsCmd } from "@/lib/tauriCommands"; import { getAppVersionCmd, loadAiProvidersCmd, testProviderConnectionCmd, shutdownPortForwardsCmd } from "@/lib/tauriCommands";
@ -34,11 +35,13 @@ import MCPServers from "@/pages/Settings/MCPServers";
import Security from "@/pages/Settings/Security"; import Security from "@/pages/Settings/Security";
import ShellExecution from "@/pages/Settings/ShellExecution"; import ShellExecution from "@/pages/Settings/ShellExecution";
import KubeconfigManager from "@/pages/Settings/KubeconfigManager"; import KubeconfigManager from "@/pages/Settings/KubeconfigManager";
import { KubernetesPage } from "@/pages/Kubernetes/KubernetesPage";
import { ShellApprovalModal } from "@/components/ShellApprovalModal"; import { ShellApprovalModal } from "@/components/ShellApprovalModal";
const navItems = [ const navItems = [
{ to: "/", icon: Home, label: "Dashboard" }, { to: "/", icon: Home, label: "Dashboard" },
{ to: "/new-issue", icon: Plus, label: "New Issue" }, { to: "/new-issue", icon: Plus, label: "New Issue" },
{ to: "/kubernetes", icon: Server, label: "Kubernetes" },
{ to: "/history", icon: Clock, label: "History" }, { to: "/history", icon: Clock, label: "History" },
]; ];
@ -197,6 +200,7 @@ export default function App() {
<Route path="/settings/ollama" element={<Ollama />} /> <Route path="/settings/ollama" element={<Ollama />} />
<Route path="/settings/shell" element={<ShellExecution />} /> <Route path="/settings/shell" element={<ShellExecution />} />
<Route path="/settings/kubeconfig" element={<KubeconfigManager />} /> <Route path="/settings/kubeconfig" element={<KubeconfigManager />} />
<Route path="/kubernetes" element={<KubernetesPage />} />
<Route path="/settings/integrations" element={<Integrations />} /> <Route path="/settings/integrations" element={<Integrations />} />
<Route path="/settings/mcp" element={<MCPServers />} /> <Route path="/settings/mcp" element={<MCPServers />} />
<Route path="/settings/security" element={<Security />} /> <Route path="/settings/security" element={<Security />} />

View File

@ -0,0 +1,51 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { DaemonSetInfo } from "@/lib/tauriCommands";
interface DaemonSetListProps {
daemonsets: DaemonSetInfo[];
clusterId: string;
namespace: string;
}
export function DaemonSetList({ daemonsets, clusterId, namespace }: DaemonSetListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Desired</TableHead>
<TableHead>Current</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Up-to-date</TableHead>
<TableHead>Available</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{daemonsets.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No daemonsets found
</TableCell>
</TableRow>
) : (
daemonsets.map((ds) => (
<TableRow key={ds.name}>
<TableCell className="font-medium">{ds.name}</TableCell>
<TableCell>{ds.desired}</TableCell>
<TableCell>{ds.current}</TableCell>
<TableCell>{ds.ready}</TableCell>
<TableCell>{ds.up_to_date}</TableCell>
<TableCell>{ds.available}</TableCell>
<TableCell className="text-muted-foreground">{ds.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,209 @@
import React, { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Button } from "@/components/ui";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui";
import { Input } from "@/components/ui";
import { Label } from "@/components/ui";
import { Alert, AlertDescription } from "@/components/ui";
import { AlertCircle, RotateCcw, Scale } from "lucide-react";
import type { DeploymentInfo } from "@/lib/tauriCommands";
interface DeploymentListProps {
deployments: DeploymentInfo[];
clusterId: string;
namespace: string;
}
export function DeploymentList({ deployments, clusterId, namespace }: DeploymentListProps) {
const [scalingDeployment, setScalingDeployment] = useState<DeploymentInfo | null>(null);
const [replicas, setReplicas] = useState<string>("");
const [isScaling, setIsScaling] = useState(false);
const [scaleError, setScaleError] = useState<string | null>(null);
const [restartingDeployment, setRestartingDeployment] = useState<DeploymentInfo | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const handleScaleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setReplicas(e.target.value);
setScaleError(null);
};
const handleScaleSubmit = async () => {
if (!scalingDeployment) return;
const newReplicas = parseInt(replicas, 10);
if (isNaN(newReplicas) || newReplicas < 0) {
setScaleError("Invalid replica count");
return;
}
setIsScaling(true);
setScaleError(null);
try {
await invoke<void>("scale_deployment", {
clusterId,
namespace,
deploymentName: scalingDeployment.name,
replicas: newReplicas,
});
setScalingDeployment(null);
setReplicas("");
} catch (err) {
console.error("Failed to scale deployment:", err);
setScaleError(err instanceof Error ? err.message : "Failed to scale deployment");
} finally {
setIsScaling(false);
}
};
const handleRestartSubmit = async () => {
if (!restartingDeployment) return;
setIsRestarting(true);
setRestartError(null);
try {
await invoke<void>("restart_deployment", {
clusterId,
namespace,
deploymentName: restartingDeployment.name,
});
setRestartingDeployment(null);
} catch (err) {
console.error("Failed to restart deployment:", err);
setRestartError(err instanceof Error ? err.message : "Failed to restart deployment");
} finally {
setIsRestarting(false);
}
};
return (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Up-to-date</TableHead>
<TableHead>Available</TableHead>
<TableHead>Replicas</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deployments.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No deployments found
</TableCell>
</TableRow>
) : (
deployments.map((deployment) => (
<TableRow key={deployment.name}>
<TableCell className="font-medium">{deployment.name}</TableCell>
<TableCell>{deployment.ready}</TableCell>
<TableCell>{deployment.up_to_date}</TableCell>
<TableCell>{deployment.available}</TableCell>
<TableCell>{deployment.replicas}</TableCell>
<TableCell className="text-muted-foreground">{deployment.age}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setScalingDeployment(deployment)}
>
<Scale className="w-4 h-4" />
Scale
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setRestartingDeployment(deployment)}
>
<RotateCcw className="w-4 h-4" />
Restart
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Scale Dialog */}
<Dialog open={!!scalingDeployment} onOpenChange={() => setScalingDeployment(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Scale Deployment</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="replicas">Replica Count</Label>
<Input
id="replicas"
type="number"
value={replicas}
onChange={handleScaleChange}
placeholder="Enter replica count"
min="0"
/>
{scaleError && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{scaleError}</AlertDescription>
</Alert>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setScalingDeployment(null)}>
Cancel
</Button>
<Button onClick={handleScaleSubmit} disabled={isScaling}>
{isScaling ? "Scaling..." : "Scale"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Restart Dialog */}
<Dialog open={!!restartingDeployment} onOpenChange={() => setRestartingDeployment(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Restart Deployment</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
This will trigger a rolling restart of the deployment.
</p>
{restartError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{restartError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRestartingDeployment(null)}>
Cancel
</Button>
<Button onClick={handleRestartSubmit} disabled={isRestarting}>
{isRestarting ? "Restarting..." : "Restart"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,200 @@
import React, { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Badge } from "@/components/ui";
import { Button } from "@/components/ui";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui";
import { Textarea } from "@/components/ui";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui";
import { Terminal, FileText, RotateCcw } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui";
import type { PodInfo, LogResponse } from "@/lib/tauriCommands";
interface PodListProps {
pods: PodInfo[];
clusterId: string;
namespace: string;
}
export function PodList({ pods, clusterId, namespace }: PodListProps) {
const [selectedPod, setSelectedPod] = useState<PodInfo | null>(null);
const [selectedContainer, setSelectedContainer] = useState<string>("");
const [logs, setLogs] = useState<string>("");
const [isFetchingLogs, setIsFetchingLogs] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const getPodStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case "running":
return "bg-green-500";
case "pending":
return "bg-yellow-500";
case "succeeded":
case "completed":
return "bg-blue-500";
case "failed":
case "error":
return "bg-red-500";
default:
return "bg-gray-500";
}
};
const fetchLogs = async () => {
if (!selectedPod || !selectedContainer) return;
setIsFetchingLogs(true);
setError(null);
try {
const response = await invoke<LogResponse>("get_pod_logs", {
clusterId,
namespace,
podName: selectedPod.name,
containerName: selectedContainer,
});
setLogs(response.logs);
} catch (err) {
console.error("Failed to fetch logs:", err);
setError(err instanceof Error ? err.message : "Failed to fetch logs");
} finally {
setIsFetchingLogs(false);
}
};
const handleContainerChange = (container: string) => {
setSelectedContainer(container);
setLogs("");
setError(null);
};
const containers = selectedPod ? [selectedPod.name] : [];
return (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pods.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No pods found
</TableCell>
</TableRow>
) : (
pods.map((pod) => (
<TableRow key={pod.name}>
<TableCell className="font-medium">{pod.name}</TableCell>
<TableCell>
<Badge className={`${getPodStatusColor(pod.status)} text-white`}>
{pod.status}
</Badge>
</TableCell>
<TableCell>{pod.ready}</TableCell>
<TableCell className="text-muted-foreground">{pod.age}</TableCell>
<TableCell className="text-right">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<Button variant="ghost" size="sm" onClick={() => { setSelectedPod(pod); setIsDialogOpen(true); }}>
<Terminal className="w-4 h-4" />
</Button>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>{pod.name} - {namespace} namespace</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
{selectedPod && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Container:</span>
<select
value={selectedContainer}
onChange={(e) => handleContainerChange(e.target.value)}
className="flex h-9 w-32 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="">Select container...</option>
{containers.map((container) => (
<option key={container} value={container}>
{container}
</option>
))}
</select>
<Button
onClick={fetchLogs}
disabled={!selectedContainer || isFetchingLogs}
size="sm"
>
{isFetchingLogs ? (
<>
<RotateCcw className="w-4 h-4 animate-spin" />
Loading...
</>
) : (
<>
<FileText className="w-4 h-4" />
Fetch Logs
</>
)}
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Tabs value="logs" onValueChange={() => {}}>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-auto">
<TabsContent value="logs" className="h-full">
<Textarea
value={logs}
readOnly
className="font-mono text-xs h-64"
placeholder="No logs available. Click 'Fetch Logs' to retrieve."
/>
</TabsContent>
<TabsContent value="details" className="h-full">
<div className="space-y-2 text-sm">
<div className="grid grid-cols-2 gap-2">
<div className="text-muted-foreground">Name:</div>
<div>{selectedPod.name}</div>
<div className="text-muted-foreground">Status:</div>
<div>{selectedPod.status}</div>
<div className="text-muted-foreground">Ready:</div>
<div>{selectedPod.ready}</div>
<div className="text-muted-foreground">Age:</div>
<div>{selectedPod.age}</div>
</div>
</div>
</TabsContent>
</div>
</Tabs>
</div>
)}
</div>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</>
);
}

View File

@ -0,0 +1,169 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui";
import { Button } from "@/components/ui";
import { Loader2, AlertCircle } from "lucide-react";
import type { NamespaceInfo, PodInfo, ServiceInfo, DeploymentInfo, StatefulSetInfo, DaemonSetInfo } from "@/lib/tauriCommands";
import { listNamespacesCmd, listPodsCmd, listServicesCmd, listDeploymentsCmd, listStatefulsetsCmd, listDaemonsetsCmd } from "@/lib/tauriCommands";
import { PodList } from "./PodList";
import { ServiceList } from "./ServiceList";
import { DeploymentList } from "./DeploymentList";
import { StatefulSetList } from "./StatefulSetList";
import { DaemonSetList } from "./DaemonSetList";
type ResourceType = "pods" | "services" | "deployments" | "statefulsets" | "daemonsets";
interface ResourceBrowserProps {
clusterId: string;
}
export function ResourceBrowser({ clusterId }: ResourceBrowserProps) {
const [namespaces, setNamespaces] = useState<NamespaceInfo[]>([]);
const [selectedNamespace, setSelectedNamespace] = useState<string>("all");
const [resourceType, setResourceType] = useState<ResourceType>("pods");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pods, setPods] = useState<PodInfo[]>([]);
const [services, setServices] = useState<ServiceInfo[]>([]);
const [deployments, setDeployments] = useState<DeploymentInfo[]>([]);
const [statefulsets, setStatefulsets] = useState<StatefulSetInfo[]>([]);
const [daemonsets, setDaemonsets] = useState<DaemonSetInfo[]>([]);
useEffect(() => {
loadData();
}, [clusterId, selectedNamespace, resourceType]);
const loadData = async () => {
setIsLoading(true);
setError(null);
try {
const [namespacesData, podsData, servicesData, deploymentsData, statefulsetsData, daemonsetsData] = await Promise.all([
listNamespacesCmd(clusterId),
selectedNamespace === "all" ? listPodsCmd(clusterId, "") : listPodsCmd(clusterId, selectedNamespace),
selectedNamespace === "all" ? listServicesCmd(clusterId, "") : listServicesCmd(clusterId, selectedNamespace),
selectedNamespace === "all" ? listDeploymentsCmd(clusterId, "") : listDeploymentsCmd(clusterId, selectedNamespace),
selectedNamespace === "all" ? listStatefulsetsCmd(clusterId, "") : listStatefulsetsCmd(clusterId, selectedNamespace),
selectedNamespace === "all" ? listDaemonsetsCmd(clusterId, "") : listDaemonsetsCmd(clusterId, selectedNamespace),
]);
setNamespaces(namespacesData);
setPods(podsData);
setServices(servicesData);
setDeployments(deploymentsData);
setStatefulsets(statefulsetsData);
setDaemonsets(daemonsetsData);
} catch (err) {
console.error("Failed to load resources:", err);
setError(err instanceof Error ? err.message : "Failed to load resources");
} finally {
setIsLoading(false);
}
};
const getNamespaceOptions = () => {
const options = [{ name: "All Namespaces", value: "all" }];
namespaces.forEach(ns => {
options.push({ name: ns.name, value: ns.name });
});
return options;
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-muted-foreground">Loading Kubernetes resources...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<Card className="w-full max-w-md">
<CardContent className="pt-6">
<div className="flex flex-col items-center gap-4">
<AlertCircle className="w-12 h-12 text-destructive" />
<p className="text-center text-muted-foreground">{error}</p>
<Button onClick={loadData}>Retry</Button>
</div>
</CardContent>
</Card>
</div>
);
}
const renderResourceList = () => {
switch (resourceType) {
case "pods":
return <PodList pods={pods} clusterId={clusterId} namespace={selectedNamespace} />;
case "services":
return <ServiceList services={services} clusterId={clusterId} namespace={selectedNamespace} />;
case "deployments":
return <DeploymentList deployments={deployments} clusterId={clusterId} namespace={selectedNamespace} />;
case "statefulsets":
return <StatefulSetList statefulsets={statefulsets} clusterId={clusterId} namespace={selectedNamespace} />;
case "daemonsets":
return <DaemonSetList daemonsets={daemonsets} clusterId={clusterId} namespace={selectedNamespace} />;
default:
return null;
}
};
return (
<div className="h-full overflow-y-auto p-6 space-y-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Kubernetes Resources</h1>
<p className="text-muted-foreground">
Browse and manage your Kubernetes resources
</p>
</div>
<Card>
<CardHeader>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Select value={selectedNamespace} onValueChange={setSelectedNamespace}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select namespace" />
</SelectTrigger>
<SelectContent>
{getNamespaceOptions().map((ns) => (
<SelectItem key={ns.value} value={ns.value}>
{ns.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
</Card>
<Card className="flex-1 flex flex-col">
<CardHeader>
<Tabs value={resourceType} onValueChange={(v) => setResourceType(v as ResourceType)}>
<TabsList className="grid grid-cols-5">
<TabsTrigger value="pods">Pods</TabsTrigger>
<TabsTrigger value="services">Services</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="statefulsets">StatefulSets</TabsTrigger>
<TabsTrigger value="daemonsets">DaemonSets</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent className="flex-1 overflow-hidden">
<div className="h-full overflow-y-auto">
<TabsContent value={resourceType} className="h-full">
{renderResourceList()}
</TabsContent>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,81 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Badge } from "@/components/ui";
import type { ServiceInfo } from "@/lib/tauriCommands";
interface ServiceListProps {
services: ServiceInfo[];
clusterId: string;
namespace: string;
}
export function ServiceList({ services, clusterId, namespace }: ServiceListProps) {
const getServiceTypeColor = (type: string) => {
switch (type.toLowerCase()) {
case "clusterip":
return "bg-blue-500";
case "nodeport":
return "bg-purple-500";
case "loadbalancer":
return "bg-green-500";
case "externalname":
return "bg-gray-500";
default:
return "bg-gray-500";
}
};
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Cluster IP</TableHead>
<TableHead>External IP</TableHead>
<TableHead>Ports</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No services found
</TableCell>
</TableRow>
) : (
services.map((service) => (
<TableRow key={`${service.name}-${service.namespace}`}>
<TableCell className="font-medium">{service.name}</TableCell>
<TableCell>
<Badge className={`${getServiceTypeColor(service.type)} text-white`}>
{service.type}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm">{service.cluster_ip}</TableCell>
<TableCell className="font-mono text-sm">
{service.external_ip || "N/A"}
</TableCell>
<TableCell>
<div className="space-y-1">
{service.ports.map((port) => (
<div key={`${port.port}-${port.protocol}`} className="text-sm">
{port.name ? `${port.name}: ` : ""}
{port.port}/{port.protocol}
{port.target_port && `${port.target_port}`}
</div>
))}
</div>
</TableCell>
<TableCell className="text-muted-foreground">{service.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,45 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import type { StatefulSetInfo } from "@/lib/tauriCommands";
interface StatefulSetListProps {
statefulsets: StatefulSetInfo[];
clusterId: string;
namespace: string;
}
export function StatefulSetList({ statefulsets, clusterId, namespace }: StatefulSetListProps) {
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Replicas</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{statefulsets.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No statefulsets found
</TableCell>
</TableRow>
) : (
statefulsets.map((ss) => (
<TableRow key={ss.name}>
<TableCell className="font-medium">{ss.name}</TableCell>
<TableCell>{ss.ready}</TableCell>
<TableCell>{ss.replicas}</TableCell>
<TableCell className="text-muted-foreground">{ss.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -2,3 +2,9 @@ export { ClusterList } from "./ClusterList";
export { PortForwardList } from "./PortForwardList"; export { PortForwardList } from "./PortForwardList";
export { AddClusterModal } from "./AddClusterModal"; export { AddClusterModal } from "./AddClusterModal";
export { PortForwardForm } from "./PortForwardForm"; export { PortForwardForm } from "./PortForwardForm";
export { ResourceBrowser } from "./ResourceBrowser";
export { PodList } from "./PodList";
export { ServiceList } from "./ServiceList";
export { DeploymentList } from "./DeploymentList";
export { StatefulSetList } from "./StatefulSetList";
export { DaemonSetList } from "./DaemonSetList";

View File

@ -412,4 +412,295 @@ export const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemP
); );
RadioGroupItem.displayName = "RadioGroupItem"; RadioGroupItem.displayName = "RadioGroupItem";
// ─── Table ────────────────────────────────────────────────────────────────────
export const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
export const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
export const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
export const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
export const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement> & { hover?: boolean }
>(({ className, hover, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
export const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
export const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
export const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
// ─── Tabs ─────────────────────────────────────────────────────────────────────
const TabsContext = React.createContext<{
value: string;
onValueChange: (value: string) => void;
} | null>(null);
export function Tabs({ value, onValueChange, children }: { value: string; onValueChange: (value: string) => void; children: React.ReactNode }) {
return (
<TabsContext.Provider value={{ value, onValueChange }}>
<div className="w-full">{children}</div>
</TabsContext.Provider>
);
}
export function TabsList({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<div
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
>
{children}
</div>
);
}
export function TabsTrigger({ className, children, value }: { className?: string; children: React.ReactNode; value: string }) {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error("TabsTrigger must be used within Tabs");
return (
<button
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
ctx.value === value
? "bg-background text-foreground shadow-sm"
: "hover:bg-background hover:text-foreground",
className
)}
onClick={() => ctx.onValueChange(value)}
>
{children}
</button>
);
}
export function TabsContent({ className, children, value }: { className?: string; children: React.ReactNode; value: string }) {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error("TabsContent must be used within Tabs");
return (
<div
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
ctx.value === value ? "block" : "hidden",
className
)}
>
{children}
</div>
);
}
// ─── Dialog ───────────────────────────────────────────────────────────────────
const DialogContext = React.createContext<{
open: boolean;
onOpenChange: (open: boolean) => void;
} | null>(null);
export function Dialog({ open, onOpenChange, children }: { open: boolean; onOpenChange: (open: boolean) => void; children: React.ReactNode }) {
return (
<DialogContext.Provider value={{ open, onOpenChange }}>
{children}
</DialogContext.Provider>
);
}
export function DialogTrigger({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
export function DialogContent({ className, children }: { className?: string; children: React.ReactNode }) {
const ctx = React.useContext(DialogContext);
if (!ctx) throw new Error("DialogContent must be used within Dialog");
if (!ctx.open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg",
className
)}
>
{children}
</div>
</div>
);
}
export function DialogHeader({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}>
{children}
</div>
);
}
export function DialogFooter({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}>
{children}
</div>
);
}
export function DialogTitle({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<h2 className={cn("text-lg font-semibold leading-none tracking-tight", className)}>
{children}
</h2>
);
}
export function DialogDescription({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>
{children}
</p>
);
}
// ─── Alert ───────────────────────────────────────────────────────────────────
const alertVariants = cva(
"relative w-full rounded-lg border p-4",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof alertVariants> {}
export function Alert({ className, variant, children, ...props }: AlertProps) {
return (
<div
className={cn(alertVariants({ variant }), className)}
role="alert"
{...props}
>
{children}
</div>
);
}
export function AlertTitle({ className, children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h5
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
>
{children}
</h5>
);
}
export function AlertDescription({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
>
{children}
</div>
);
}
export { cn }; export { cn };

View File

@ -783,6 +783,101 @@ export interface ClusterConnectionStatus {
context: string; context: string;
} }
// ─── Kubernetes Resource Discovery Types ──────────────────────────────────────
export interface NamespaceInfo {
name: string;
status: string;
age: string;
}
export interface ServicePort {
name?: string;
port: number;
target_port?: string;
protocol: string;
}
export interface ServiceInfo {
name: string;
namespace: string;
type: string;
cluster_ip: string;
external_ip?: string;
ports: ServicePort[];
age: string;
selector: Record<string, string>;
}
export interface DeploymentInfo {
name: string;
namespace: string;
ready: string;
up_to_date: string;
available: string;
age: string;
replicas: number;
labels: Record<string, string>;
}
export interface StatefulSetInfo {
name: string;
namespace: string;
ready: string;
age: string;
replicas: number;
labels: Record<string, string>;
}
export interface DaemonSetInfo {
name: string;
namespace: string;
desired: number;
current: number;
ready: number;
up_to_date: number;
available: number;
age: string;
labels: Record<string, string>;
}
export interface NodeMetrics {
name: string;
cpu_usage: string;
memory_usage: string;
cpu_percentage: number;
memory_percentage: number;
age: string;
}
export interface PodMetrics {
name: string;
namespace: string;
cpu_usage: string;
memory_usage: string;
cpu_percentage: number;
memory_percentage: number;
}
export interface LogResponse {
logs: string;
}
export interface ExecResponse {
stdout: string;
stderr: string;
exit_code: number | null;
}
export interface ExecSessionResponse {
session_id: string;
cluster_id: string;
namespace: string;
pod: string;
container?: string;
status: string;
}
// ─── Kubernetes Management Commands ─────────────────────────────────────────── // ─── Kubernetes Management Commands ───────────────────────────────────────────
export const addClusterCmd = (id: string, name: string, kubeconfigContent: string) => export const addClusterCmd = (id: string, name: string, kubeconfigContent: string) =>
@ -814,3 +909,40 @@ export const testClusterConnectionCmd = (clusterId: string) =>
export const discoverPodsCmd = (clusterId: string, namespace: string) => export const discoverPodsCmd = (clusterId: string, namespace: string) =>
invoke<PodInfo[]>("discover_pods", { clusterId, namespace }); invoke<PodInfo[]>("discover_pods", { clusterId, namespace });
// ─── Kubernetes Resource Discovery Commands ───────────────────────────────────
export const listNamespacesCmd = (clusterId: string) =>
invoke<NamespaceInfo[]>("list_namespaces", { clusterId });
export const listPodsCmd = (clusterId: string, namespace: string) =>
invoke<PodInfo[]>("list_pods", { clusterId, namespace });
export const listServicesCmd = (clusterId: string, namespace: string) =>
invoke<ServiceInfo[]>("list_services", { clusterId, namespace });
export const listDeploymentsCmd = (clusterId: string, namespace: string) =>
invoke<DeploymentInfo[]>("list_deployments", { clusterId, namespace });
export const listStatefulsetsCmd = (clusterId: string, namespace: string) =>
invoke<StatefulSetInfo[]>("list_statefulsets", { clusterId, namespace });
export const listDaemonsetsCmd = (clusterId: string, namespace: string) =>
invoke<DaemonSetInfo[]>("list_daemonsets", { clusterId, namespace });
// ─── Kubernetes Resource Management Commands ──────────────────────────────────
export const getPodLogsCmd = (clusterId: string, namespace: string, podName: string, containerName: string) =>
invoke<LogResponse>("get_pod_logs", { clusterId, namespace, podName, containerName });
export const scaleDeploymentCmd = (clusterId: string, namespace: string, deploymentName: string, replicas: number) =>
invoke<void>("scale_deployment", { clusterId, namespace, deploymentName, replicas });
export const restartDeploymentCmd = (clusterId: string, namespace: string, deploymentName: string) =>
invoke<void>("restart_deployment", { clusterId, namespace, deploymentName });
export const deleteResourceCmd = (clusterId: string, resourceType: string, namespace: string, resourceName: string) =>
invoke<void>("delete_resource", { clusterId, resourceType, namespace, resourceName });
export const execPodCmd = (clusterId: string, namespace: string, podName: string, containerName: string, command: string) =>
invoke<ExecResponse>("exec_pod", { clusterId, namespace, podName, containerName, command });