diff --git a/src/components/Kubernetes/ApplicationView.tsx b/src/components/Kubernetes/ApplicationView.tsx new file mode 100644 index 00000000..039e388f --- /dev/null +++ b/src/components/Kubernetes/ApplicationView.tsx @@ -0,0 +1,134 @@ +import React from "react"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui"; +import { Terminal } from "./Terminal"; +import { SearchBar } from "./SearchBar"; +import { MetricsChart } from "./MetricsChart"; +import { YamlEditor } from "./YamlEditor"; +import { useKubernetesStore } from "@/stores/kubernetesStore"; +import { useStore } from "zustand"; + +interface ApplicationViewProps { + clusterId: string; + namespace: string; +} + +export function ApplicationView({ clusterId, namespace }: ApplicationViewProps) { + const [activeTab, setActiveTab] = React.useState("overview"); + const clusters = useStore(useKubernetesStore, (state) => state.clusters); + const selectedCluster = clusters.find((c: { id: string }) => c.id === clusterId); + + return ( +
+
+
+

Application View

+ {selectedCluster && ( + {selectedCluster.name} + )} +
+
+ state.globalSearchQuery)} + onQueryChange={(q) => useKubernetesStore.getState().setGlobalSearchQuery(q)} + /> +
+
+ + + + Overview + Workloads + Infrastructure + Terminal + YAML + + +
+ +
+ + + + +
+
+ + +
+

Workloads will be displayed here

+
+
+ + +
+

Infrastructure resources will be displayed here

+
+
+ + + + + + + {}} /> + +
+
+
+ ); +} diff --git a/src/components/Kubernetes/ClusterRoleBindingList.tsx b/src/components/Kubernetes/ClusterRoleBindingList.tsx new file mode 100644 index 00000000..218349b3 --- /dev/null +++ b/src/components/Kubernetes/ClusterRoleBindingList.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { ClusterRoleBindingInfo } from "@/lib/tauriCommands"; + +interface ClusterRoleBindingListProps { + clusterRoleBindings: ClusterRoleBindingInfo[]; + _clusterId: string; +} + +export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: ClusterRoleBindingListProps) { + return ( +
+ + + + Name + Cluster Role + Age + + + + {clusterRoleBindings.length === 0 ? ( + + + No cluster role bindings found + + + ) : ( + clusterRoleBindings.map((crb) => ( + + {crb.name} + {crb.cluster_role} + {crb.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/ClusterRoleList.tsx b/src/components/Kubernetes/ClusterRoleList.tsx new file mode 100644 index 00000000..56f94c58 --- /dev/null +++ b/src/components/Kubernetes/ClusterRoleList.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { ClusterRoleInfo } from "@/lib/tauriCommands"; + +interface ClusterRoleListProps { + clusterRoles: ClusterRoleInfo[]; + _clusterId: string; +} + +export function ClusterRoleList({ clusterRoles, _clusterId }: ClusterRoleListProps) { + return ( +
+ + + + Name + Age + + + + {clusterRoles.length === 0 ? ( + + + No cluster roles found + + + ) : ( + clusterRoles.map((clusterRole) => ( + + {clusterRole.name} + {clusterRole.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/ConfigMapList.tsx b/src/components/Kubernetes/ConfigMapList.tsx new file mode 100644 index 00000000..64ab1fe9 --- /dev/null +++ b/src/components/Kubernetes/ConfigMapList.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Button } from "@/components/ui"; +import type { ConfigMapInfo } from "@/lib/tauriCommands"; + +interface ConfigMapListProps { + configmaps: ConfigMapInfo[]; + clusterId: string; + namespace: string; +} + +export function ConfigMapList({ configmaps }: ConfigMapListProps) { + + return ( +
+ + + + Name + Namespace + Data Keys + Age + Actions + + + + {configmaps.length === 0 ? ( + + + No configmaps found + + + ) : ( + configmaps.map((configmap) => ( + + {configmap.name} + {configmap.namespace} + {configmap.data_keys} + {configmap.age} + + + + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/ContextSwitcher.tsx b/src/components/Kubernetes/ContextSwitcher.tsx new file mode 100644 index 00000000..39ba4bd0 --- /dev/null +++ b/src/components/Kubernetes/ContextSwitcher.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { Server } from "lucide-react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui"; +import { Badge } from "@/components/ui"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui"; + +interface ContextSwitcherProps { + clusters: { id: string; name: string; context: string; cluster_url?: string }[]; + selectedClusterId: string; + onClusterChange: (clusterId: string) => void; +} + +export function ContextSwitcher({ clusters, selectedClusterId, onClusterChange }: ContextSwitcherProps) { + const selectedCluster = clusters.find((c) => c.id === selectedClusterId); + + return ( + + + + + Context Switcher + + + +
+
+ + +
+ + {selectedCluster && ( +
+
+ Context + {selectedCluster.context} +
+ {selectedCluster.cluster_url && ( +
+ Cluster URL + {selectedCluster.cluster_url} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/components/Kubernetes/CronJobList.tsx b/src/components/Kubernetes/CronJobList.tsx new file mode 100644 index 00000000..6570d5d4 --- /dev/null +++ b/src/components/Kubernetes/CronJobList.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { CronJobInfo } from "@/lib/tauriCommands"; + +interface CronJobListProps { + cronJobs: CronJobInfo[]; + _clusterId: string; + _namespace: string; +} + +export function CronJobList({ cronJobs, _clusterId, _namespace }: CronJobListProps) { + return ( +
+ + + + Name + Namespace + Schedule + Active + Last Schedule + Age + Labels + + + + {cronJobs.length === 0 ? ( + + + No cron jobs found + + + ) : ( + cronJobs.map((cronJob) => ( + + {cronJob.name} + {cronJob.namespace} + {cronJob.schedule} + {cronJob.active} + {cronJob.last_schedule} + {cronJob.age} + + {Object.entries(cronJob.labels) + .map(([k, v]) => `${k}=${v}`) + .join(", ")} + + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/EventList.tsx b/src/components/Kubernetes/EventList.tsx new file mode 100644 index 00000000..7b9e856a --- /dev/null +++ b/src/components/Kubernetes/EventList.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Badge } from "@/components/ui"; +import type { EventInfo } from "@/lib/tauriCommands"; + +interface EventListProps { + events: EventInfo[]; + clusterId: string; + namespace?: string; +} + +export function EventList({ events, clusterId: _clusterId, namespace: _namespace }: EventListProps) { + const getEventTypeColor = (type: string) => { + switch (type.toLowerCase()) { + case "normal": + return "bg-blue-500"; + case "warning": + return "bg-yellow-500 text-yellow-900"; + default: + return "bg-gray-500"; + } + }; + + return ( +
+ + + + Name + Type + Reason + Object + Count + First Seen + Last Seen + Message + + + + {events.length === 0 ? ( + + + No events found + + + ) : ( + events.map((event) => ( + + {event.name} + + + {event.event_type} + + + {event.reason} + {event.object} + {event.count} + {event.first_seen} + {event.last_seen} + + {event.message} + + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/HPAList.tsx b/src/components/Kubernetes/HPAList.tsx new file mode 100644 index 00000000..d486106d --- /dev/null +++ b/src/components/Kubernetes/HPAList.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { HorizontalPodAutoscalerInfo } from "@/lib/tauriCommands"; + +interface HPAListProps { + hpas: HorizontalPodAutoscalerInfo[]; + _clusterId: string; + _namespace: string; +} + +export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) { + return ( +
+ + + + Name + Namespace + Min Replicas + Max Replicas + Current Replicas + Desired Replicas + Age + + + + {hpas.length === 0 ? ( + + + No HPAs found + + + ) : ( + hpas.map((hpa) => ( + + {hpa.name} + {hpa.namespace} + {hpa.min_replicas} + {hpa.max_replicas} + {hpa.current_replicas} + {hpa.desired_replicas} + {hpa.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/IngressList.tsx b/src/components/Kubernetes/IngressList.tsx new file mode 100644 index 00000000..59fee670 --- /dev/null +++ b/src/components/Kubernetes/IngressList.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { IngressInfo } from "@/lib/tauriCommands"; + +interface IngressListProps { + ingresses: IngressInfo[]; + _clusterId: string; + _namespace: string; +} + +export function IngressList({ ingresses, _clusterId, _namespace }: IngressListProps) { + return ( +
+ + + + Name + Namespace + Class + Host + Addresses + Age + + + + {ingresses.length === 0 ? ( + + + No ingresses found + + + ) : ( + ingresses.map((ingress) => ( + + {ingress.name} + {ingress.namespace} + {ingress.class || "-"} + {ingress.host} + {ingress.addresses.join(", ")} + {ingress.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/JobList.tsx b/src/components/Kubernetes/JobList.tsx new file mode 100644 index 00000000..f589d3ce --- /dev/null +++ b/src/components/Kubernetes/JobList.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { JobInfo } from "@/lib/tauriCommands"; + +interface JobListProps { + jobs: JobInfo[]; + _clusterId: string; + _namespace: string; +} + +export function JobList({ jobs, _clusterId, _namespace }: JobListProps) { + return ( +
+ + + + Name + Namespace + Completions + Duration + Age + Labels + + + + {jobs.length === 0 ? ( + + + No jobs found + + + ) : ( + jobs.map((job) => ( + + {job.name} + {job.namespace} + {job.completions} + {job.duration} + {job.age} + + {Object.entries(job.labels) + .map(([k, v]) => `${k}=${v}`) + .join(", ")} + + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/MetricsChart.tsx b/src/components/Kubernetes/MetricsChart.tsx new file mode 100644 index 00000000..74cdf65d --- /dev/null +++ b/src/components/Kubernetes/MetricsChart.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui"; + +interface MetricsChartProps { + title: string; + data: { labels: string[]; datasets: { label: string; data: number[]; borderColor?: string; backgroundColor?: string }[] }; + type?: "line" | "bar"; + timeRange?: string; + onTimeRangeChange?: (range: string) => void; +} + +export function MetricsChart({ title, data, timeRange = "5m", onTimeRangeChange }: MetricsChartProps) { + const timeRanges = ["5m", "15m", "1h", "6h", "1d", "7d"]; + + return ( + + +
+ {title} + {onTimeRangeChange && ( +
+ Time Range: + +
+ )} +
+
+ + {data.datasets.length > 0 ? ( +
+

Chart visualization would be displayed here

+

Charts require react-chartjs-2 and chart.js dependencies

+
+ ) : ( +
+ No metrics data available +
+ )} +
+
+ ); +} diff --git a/src/components/Kubernetes/NodeList.tsx b/src/components/Kubernetes/NodeList.tsx new file mode 100644 index 00000000..ad2b68be --- /dev/null +++ b/src/components/Kubernetes/NodeList.tsx @@ -0,0 +1,233 @@ +import React, { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +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 { AlertCircle, Terminal } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui"; +import type { NodeInfo } from "@/lib/tauriCommands"; + +interface NodeListProps { + nodes: NodeInfo[]; + clusterId: string; +} + +export function NodeList({ nodes, clusterId }: NodeListProps) { + const [selectedNode, setSelectedNode] = useState(null); + const [isCordoning, setIsCordoning] = useState(false); + const [isUncordoning, setIsUncordoning] = useState(false); + const [isDraining, setIsDraining] = useState(false); + const [error, setError] = useState(null); + + const getNodeStatusColor = (status: string) => { + switch (status.toLowerCase()) { + case "ready": + return "bg-green-500"; + case "notready": + return "bg-red-500"; + case "schedulingdisabled": + return "bg-yellow-500"; + default: + return "bg-gray-500"; + } + }; + + const handleCordon = async () => { + if (!selectedNode) return; + + setIsCordoning(true); + setError(null); + try { + await invoke("cordon_node", { clusterId, nodeName: selectedNode.name }); + setSelectedNode(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to cordon node"); + } finally { + setIsCordoning(false); + } + }; + + const handleUncordon = async () => { + if (!selectedNode) return; + + setIsUncordoning(true); + setError(null); + try { + await invoke("uncordon_node", { clusterId, nodeName: selectedNode.name }); + setSelectedNode(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to uncordon node"); + } finally { + setIsUncordoning(false); + } + }; + + const handleDrain = async () => { + if (!selectedNode) return; + + setIsDraining(true); + setError(null); + try { + await invoke("drain_node", { clusterId, nodeName: selectedNode.name }); + setSelectedNode(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to drain node"); + } finally { + setIsDraining(false); + } + }; + + return ( + <> +
+ + + + Name + Status + Roles + Version + Internal IP + OS Image + Age + Actions + + + + {nodes.length === 0 ? ( + + + No nodes found + + + ) : ( + nodes.map((node) => ( + + {node.name} + + + {node.status} + + + {node.roles} + {node.version} + {node.internal_ip} + {node.os_image} + {node.age} + + + + + )) + )} + +
+
+ + {/* Node Management Dialog */} + {selectedNode && ( + { + if (!open) { + setSelectedNode(null); + setError(null); + } + }}> + + + + + Manage Node: {selectedNode.name} + + + +
+ {/* Node Details */} +
+
+

Status

+

{selectedNode.status}

+
+
+

Roles

+

{selectedNode.roles}

+
+
+

Version

+

{selectedNode.version}

+
+
+

OS Image

+

{selectedNode.os_image}

+
+
+

Kernel

+

{selectedNode.kernel_version}

+
+
+

Kubelet

+

{selectedNode.kubelet_version}

+
+
+

Internal IP

+

{selectedNode.internal_ip}

+
+ {selectedNode.external_ip && ( +
+

External IP

+

{selectedNode.external_ip}

+
+ )} +
+ + {/* Action Buttons */} +
+ {selectedNode.roles.toLowerCase().includes("schedulingdisabled") ? ( + + ) : ( + + )} + + +
+ + {error && ( + + + {error} + + )} +
+
+
+ )} + + ); +} diff --git a/src/components/Kubernetes/PVCList.tsx b/src/components/Kubernetes/PVCList.tsx new file mode 100644 index 00000000..0d461754 --- /dev/null +++ b/src/components/Kubernetes/PVCList.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { PersistentVolumeClaimInfo } from "@/lib/tauriCommands"; + +interface PVCListProps { + pvcs: PersistentVolumeClaimInfo[]; + _clusterId: string; + _namespace: string; +} + +export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) { + return ( +
+ + + + Name + Namespace + Status + Volume + Capacity + Access Modes + Age + + + + {pvcs.length === 0 ? ( + + + No PVCs found + + + ) : ( + pvcs.map((pvc) => ( + + {pvc.name} + {pvc.namespace} + {pvc.status} + {pvc.volume} + {pvc.capacity} + {pvc.access_modes.join(", ")} + {pvc.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/PVList.tsx b/src/components/Kubernetes/PVList.tsx new file mode 100644 index 00000000..bb42678e --- /dev/null +++ b/src/components/Kubernetes/PVList.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { PersistentVolumeInfo } from "@/lib/tauriCommands"; + +interface PVListProps { + pvs: PersistentVolumeInfo[]; + _clusterId: string; +} + +export function PVList({ pvs, _clusterId }: PVListProps) { + return ( +
+ + + + Name + Status + Capacity + Access Modes + Reclaim Policy + Storage Class + Age + + + + {pvs.length === 0 ? ( + + + No PVs found + + + ) : ( + pvs.map((pv) => ( + + {pv.name} + {pv.status} + {pv.capacity} + {pv.access_modes.join(", ")} + {pv.reclaim_policy} + {pv.storage_class} + {pv.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/PodDetail.tsx b/src/components/Kubernetes/PodDetail.tsx new file mode 100644 index 00000000..8b88b014 --- /dev/null +++ b/src/components/Kubernetes/PodDetail.tsx @@ -0,0 +1,187 @@ +import React from "react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui"; +import { Badge } from "@/components/ui"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Button } from "@/components/ui"; +import { Copy, Terminal, X } from "lucide-react"; +import { YamlEditor } from "./YamlEditor"; + +interface PodDetailProps { + podName: string; + namespace: string; + _clusterId: string; + onClose: () => void; +} + +export function PodDetail({ podName, namespace, _clusterId, onClose }: PodDetailProps) { + const [activeTab, setActiveTab] = React.useState("overview"); + + return ( +
+
+
+

Pod: {podName}

+ {namespace} +
+ +
+ + + + Overview + Logs + YAML + Events + + +
+ +
+ + + Pod Information + + +
+ Name + {podName} +
+
+ Namespace + {namespace} +
+
+ Status + Running +
+
+ IP + 10.0.0.1 +
+
+ Node + node-1 +
+
+ Restart Count + 0 +
+
+ Created + 2 hours ago +
+
+
+ + + + Containers + + + + + + Name + Image + State + Ready + + + + + example + nginx:latest + Running + True + + +
+
+
+ + + + Labels + + +
+ app=web + tier=frontend + version=v1 +
+
+
+
+
+ + + + + Container Logs +
+ + +
+
+ +
[INFO] Starting nginx server...
+
[INFO] Listening on port 80
+
[ACCESS] GET / - 200 OK
+
[ACCESS] GET /css/style.css - 200 OK
+
[ACCESS] GET /js/app.js - 200 OK
+
[WARN] Slow response time detected
+
[ACCESS] POST /api/data - 201 Created
+
+
+
+ + + {}} /> + + + + + + + Time + Reason + Type + Message + + + + + 2 hours ago + Pulled + Normal + Container image "nginx:latest" already present on machine + + + 2 hours ago + Created + Normal + Created container example + + + 2 hours ago + Started + Normal + Started container example + + +
+
+
+
+
+ ); +} diff --git a/src/components/Kubernetes/ReplicaSetList.tsx b/src/components/Kubernetes/ReplicaSetList.tsx new file mode 100644 index 00000000..381fcd32 --- /dev/null +++ b/src/components/Kubernetes/ReplicaSetList.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { ReplicaSetInfo } from "@/lib/tauriCommands"; + +interface ReplicaSetListProps { + replicaSets: ReplicaSetInfo[]; + _clusterId: string; + _namespace: string; +} + +export function ReplicaSetList({ replicaSets, _clusterId, _namespace }: ReplicaSetListProps) { + return ( +
+ + + + Name + Namespace + Replicas + Ready + Age + Labels + + + + {replicaSets.length === 0 ? ( + + + No replica sets found + + + ) : ( + replicaSets.map((replicaSet) => ( + + {replicaSet.name} + {replicaSet.namespace} + {replicaSet.replicas} + {replicaSet.ready} + {replicaSet.age} + + {Object.entries(replicaSet.labels) + .map(([k, v]) => `${k}=${v}`) + .join(", ")} + + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/RoleBindingList.tsx b/src/components/Kubernetes/RoleBindingList.tsx new file mode 100644 index 00000000..6f02e186 --- /dev/null +++ b/src/components/Kubernetes/RoleBindingList.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { RoleBindingInfo } from "@/lib/tauriCommands"; + +interface RoleBindingListProps { + roleBindings: RoleBindingInfo[]; + _clusterId: string; + _namespace: string; +} + +export function RoleBindingList({ roleBindings, _clusterId, _namespace }: RoleBindingListProps) { + return ( +
+ + + + Name + Namespace + Role + Age + + + + {roleBindings.length === 0 ? ( + + + No role bindings found + + + ) : ( + roleBindings.map((rb) => ( + + {rb.name} + {rb.namespace} + {rb.role} + {rb.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/RoleList.tsx b/src/components/Kubernetes/RoleList.tsx new file mode 100644 index 00000000..4300cbc0 --- /dev/null +++ b/src/components/Kubernetes/RoleList.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { RoleInfo } from "@/lib/tauriCommands"; + +interface RoleListProps { + roles: RoleInfo[]; + _clusterId: string; + _namespace: string; +} + +export function RoleList({ roles, _clusterId, _namespace }: RoleListProps) { + return ( +
+ + + + Name + Namespace + Age + + + + {roles.length === 0 ? ( + + + No roles found + + + ) : ( + roles.map((role) => ( + + {role.name} + {role.namespace} + {role.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/SearchBar.tsx b/src/components/Kubernetes/SearchBar.tsx new file mode 100644 index 00000000..13076779 --- /dev/null +++ b/src/components/Kubernetes/SearchBar.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Search } from "lucide-react"; +import { Input } from "@/components/ui"; +import { Button } from "@/components/ui"; + +interface SearchBarProps { + query: string; + onQueryChange: (query: string) => void; + placeholder?: string; + showClear?: boolean; + onClear?: () => void; +} + +export function SearchBar({ query, onQueryChange, placeholder = "Search...", showClear = true, onClear }: SearchBarProps) { + const [isFocused, setIsFocused] = React.useState(false); + + const handleClear = () => { + onQueryChange(""); + onClear?.(); + }; + + return ( +
+ + onQueryChange(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + placeholder={placeholder} + className="border-none shadow-none focus-visible:ring-0 py-0 px-2 flex-1" + /> + {showClear && query && ( + + )} +
+ ); +} diff --git a/src/components/Kubernetes/SecretList.tsx b/src/components/Kubernetes/SecretList.tsx new file mode 100644 index 00000000..149c1e40 --- /dev/null +++ b/src/components/Kubernetes/SecretList.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { SecretInfo } from "@/lib/tauriCommands"; + +interface SecretListProps { + secrets: SecretInfo[]; + _clusterId: string; + _namespace: string; +} + +export function SecretList({ secrets, _clusterId, _namespace }: SecretListProps) { + return ( +
+ + + + Name + Namespace + Type + Data Keys + Age + Actions + + + + {secrets.length === 0 ? ( + + + No secrets found + + + ) : ( + secrets.map((secret) => ( + + {secret.name} + {secret.namespace} + {secret.type} + {secret.data_keys} + {secret.age} + + View/Edit + + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/ServiceAccountList.tsx b/src/components/Kubernetes/ServiceAccountList.tsx new file mode 100644 index 00000000..9c25a90b --- /dev/null +++ b/src/components/Kubernetes/ServiceAccountList.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { ServiceAccountInfo } from "@/lib/tauriCommands"; + +interface ServiceAccountListProps { + serviceAccounts: ServiceAccountInfo[]; + _clusterId: string; + _namespace: string; +} + +export function ServiceAccountList({ serviceAccounts, _clusterId, _namespace }: ServiceAccountListProps) { + return ( +
+ + + + Name + Namespace + Secrets + Age + + + + {serviceAccounts.length === 0 ? ( + + + No service accounts found + + + ) : ( + serviceAccounts.map((sa) => ( + + {sa.name} + {sa.namespace} + {sa.secrets} + {sa.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/Terminal.tsx b/src/components/Kubernetes/Terminal.tsx new file mode 100644 index 00000000..df8a31e7 --- /dev/null +++ b/src/components/Kubernetes/Terminal.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import { Terminal as TerminalIcon, X, Plus } from "lucide-react"; +import { Button } from "@/components/ui"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui"; + +interface TerminalSession { + id: string; + clusterId: string; + namespace: string; + pod: string; + container: string; + command: string; +} + +interface TerminalProps { + clusterId: string; + namespace: string; +} + +export function Terminal({ clusterId, namespace }: TerminalProps) { + const [sessions, setSessions] = React.useState([]); + const [activeSessionId, setActiveSessionId] = React.useState(null); + const [isCreating, setIsCreating] = React.useState(false); + + const terminalRefs = React.useRef void }>>({}); + const containerRefs = React.useRef>({}); + + const addSession = React.useCallback(() => { + setIsCreating(true); + const newSession: TerminalSession = { + id: `session-${Date.now()}`, + clusterId, + namespace: namespace === "all" ? "default" : namespace, + pod: "", + container: "", + command: "bash", + }; + setSessions((prev) => [...prev, newSession]); + setActiveSessionId(newSession.id); + setIsCreating(false); + }, [clusterId, namespace]); + + const removeSession = (sessionId: string) => { + setSessions((prev) => prev.filter((s) => s.id !== sessionId)); + if (activeSessionId === sessionId) { + setActiveSessionId(null); + } + if (terminalRefs.current[sessionId]) { + terminalRefs.current[sessionId].destroy(); + delete terminalRefs.current[sessionId]; + } + }; + + const resizeTerminal = (sessionId: string) => { + const terminal = terminalRefs.current[sessionId]; + const container = containerRefs.current[sessionId]; + if (terminal && container) { + // Placeholder for resize logic + // Requires xterm-addon-fit dependency + } + }; + + React.useEffect(() => { + // Initialize with a default session + if (sessions.length === 0 && !isCreating) { + addSession(); + } + }, [sessions.length, isCreating, addSession]); + + const initTerminal = (sessionId: string, element: HTMLDivElement | null) => { + if (!element || terminalRefs.current[sessionId]) return; + + // Placeholder for terminal initialization + // Requires xterm, xterm-addon-fit, xterm-addon-web-links dependencies + const terminal = { destroy: () => {} }; + terminalRefs.current[sessionId] = terminal; + containerRefs.current[sessionId] = element; + + // Handle resize + window.addEventListener("resize", () => resizeTerminal(sessionId)); + }; + + return ( +
+
+
+ +

Terminal

+
+ +
+ + {sessions.length === 0 ? ( +
+
+ +

No terminals open

+ +
+
+ ) : ( +
+ + + {sessions.map((session) => ( + + + {session.pod || "new"} / {session.container || "bash"} + + + + ))} + + + {sessions.map((session) => ( + +
initTerminal(session.id, el)} + className="w-full h-full bg-slate-900 rounded-md overflow-hidden" + /> + + ))} + +
+ )} +
+ ); +} diff --git a/src/components/Kubernetes/YamlEditor.tsx b/src/components/Kubernetes/YamlEditor.tsx new file mode 100644 index 00000000..4cbd168e --- /dev/null +++ b/src/components/Kubernetes/YamlEditor.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { Button } from "@/components/ui"; +import { Badge } from "@/components/ui"; + +interface YamlEditorProps { + onChange: (value: string) => void; +} + +export function YamlEditor({ onChange }: YamlEditorProps) { + return ( +
+
+
+

YAML Editor

+ Ready +
+
+ + +
+
+ +
+
+

YAML Editor would be displayed here

+

Requires @monaco-editor/react dependency

+
+
+
+ ); +} diff --git a/src/components/Kubernetes/index.tsx b/src/components/Kubernetes/index.tsx index 34159051..3def9745 100644 --- a/src/components/Kubernetes/index.tsx +++ b/src/components/Kubernetes/index.tsx @@ -8,3 +8,26 @@ export { ServiceList } from "./ServiceList"; export { DeploymentList } from "./DeploymentList"; export { StatefulSetList } from "./StatefulSetList"; export { DaemonSetList } from "./DaemonSetList"; +export { NodeList } from "./NodeList"; +export { EventList } from "./EventList"; +export { ConfigMapList } from "./ConfigMapList"; +export { SecretList } from "./SecretList"; +export { ReplicaSetList } from "./ReplicaSetList"; +export { JobList } from "./JobList"; +export { CronJobList } from "./CronJobList"; +export { IngressList } from "./IngressList"; +export { PVCList } from "./PVCList"; +export { PVList } from "./PVList"; +export { ServiceAccountList } from "./ServiceAccountList"; +export { RoleList } from "./RoleList"; +export { ClusterRoleList } from "./ClusterRoleList"; +export { RoleBindingList } from "./RoleBindingList"; +export { ClusterRoleBindingList } from "./ClusterRoleBindingList"; +export { HPAList } from "./HPAList"; +export { Terminal } from "./Terminal"; +export { YamlEditor } from "./YamlEditor"; +export { MetricsChart } from "./MetricsChart"; +export { SearchBar } from "./SearchBar"; +export { ContextSwitcher } from "./ContextSwitcher"; +export { ApplicationView } from "./ApplicationView"; +export { PodDetail } from "./PodDetail"; diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index 1bbb53c4..054c9523 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -748,6 +748,18 @@ export interface ClusterInfo { cluster_url: string; } +export interface ContextInfo { + name: string; + cluster: string; + user: string; +} + +export interface ResourceInfo { + name: string; + namespace: string; + [key: string]: unknown; +} + export interface PortForwardRequest { cluster_id: string; namespace: string; diff --git a/src/pages/Kubernetes/KubernetesPage.tsx b/src/pages/Kubernetes/KubernetesPage.tsx index 7bb2b5b6..7cfcac60 100644 --- a/src/pages/Kubernetes/KubernetesPage.tsx +++ b/src/pages/Kubernetes/KubernetesPage.tsx @@ -1,8 +1,10 @@ import React, { useState, useEffect } from "react"; +import { useKubernetesStore } from "@/stores/kubernetesStore"; import { ClusterList } from "@/components/Kubernetes/ClusterList"; import { PortForwardList } from "@/components/Kubernetes/PortForwardList"; import { AddClusterModal } from "@/components/Kubernetes/AddClusterModal"; import { PortForwardForm } from "@/components/Kubernetes/PortForwardForm"; +import { ResourceBrowser } from "@/components/Kubernetes/ResourceBrowser"; import type { ClusterInfo, PortForwardResponse } from "@/lib/tauriCommands"; import { listClustersCmd, @@ -13,7 +15,7 @@ import { } from "@/lib/tauriCommands"; export function KubernetesPage() { - const [clusters, setClusters] = useState([]); + const { clusters, addCluster, removeCluster, selectedClusterId } = useKubernetesStore(); const [portForwards, setPortForwards] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isAddClusterOpen, setIsAddClusterOpen] = useState(false); @@ -30,7 +32,8 @@ export function KubernetesPage() { listClustersCmd(), listPortForwardsCmd(), ]); - setClusters(clustersData); + + clustersData.forEach(addCluster); setPortForwards(portForwardsData); } catch (err) { console.error("Failed to load data:", err); @@ -42,7 +45,7 @@ export function KubernetesPage() { const handleRemoveCluster = async (clusterId: string) => { try { await removeClusterCmd(clusterId); - setClusters((prev) => prev.filter((c) => c.id !== clusterId)); + removeCluster(clusterId); } catch (err) { console.error("Failed to remove cluster:", err); alert("Failed to remove cluster"); @@ -70,7 +73,7 @@ export function KubernetesPage() { }; const handleAddCluster = (cluster: ClusterInfo) => { - setClusters((prev) => [...prev, cluster]); + addCluster(cluster); }; const handleStartPortForward = (portForward: PortForwardResponse) => { @@ -93,17 +96,41 @@ export function KubernetesPage() {

Kubernetes Management

- Manage your Kubernetes clusters and port forwarding sessions + Manage your Kubernetes clusters and resources

-
+ {/* Cluster Management Section */} +
+
+

Clusters

+ +
+ setIsAddClusterOpen(true)} onRemove={handleRemoveCluster} /> +
+ {/* Port Forwarding Section */} +
+
+

Port Forwarding

+ +
+ setIsStartPortForwardOpen(true)} @@ -112,12 +139,22 @@ export function KubernetesPage() { />
+ {/* Resource Browser Section */} + {selectedClusterId && ( +
+

Resource Browser

+ +
+ )} + + {/* Add Cluster Modal */} setIsAddClusterOpen(false)} onAdd={handleAddCluster} /> + {/* Port Forward Form */} setIsStartPortForwardOpen(false)} diff --git a/src/stores/kubernetesStore.ts b/src/stores/kubernetesStore.ts new file mode 100644 index 00000000..90e823d0 --- /dev/null +++ b/src/stores/kubernetesStore.ts @@ -0,0 +1,185 @@ +import { create } from "zustand"; +import type { ClusterInfo, ContextInfo, ResourceInfo } from "@/lib/tauriCommands"; + +export type ResourceType = + | "pods" + | "services" + | "deployments" + | "statefulsets" + | "daemonsets" + | "replicasets" + | "jobs" + | "cronjobs" + | "ingresses" + | "persistentvolumes" + | "persistentvolumeclaims" + | "configmaps" + | "secrets" + | "serviceaccounts" + | "roles" + | "clusterroles" + | "rolebindings" + | "clusterrolebindings" + | "nodes" + | "events" + | "hpas"; + +interface KubernetesState { + // Selection state + selectedClusterId: string | null; + selectedNamespace: string; + + // Data state + clusters: ClusterInfo[]; + contexts: ContextInfo[]; + namespaces: Record; // clusterId -> [namespaces] + + // Loaded resources tracking + loadedResources: Set; + + // Terminal sessions + terminalSessions: Record; + nextTerminalId: number; + + // Search state + globalSearchQuery: string; + searchResults: Record; + + // Bulk selection + bulkSelection: Record; // resourceType -> [resourceNames] + + // Actions + setSelectedCluster: (clusterId: string) => void; + setSelectedNamespace: (namespace: string) => void; + addCluster: (cluster: ClusterInfo) => void; + removeCluster: (clusterId: string) => void; + updateCluster: (clusterId: string, updates: Partial) => void; + addContext: (context: ContextInfo) => void; + setNamespaces: (clusterId: string, namespaces: string[]) => void; + markResourceLoaded: (type: ResourceType) => void; + markResourceUnloaded: (type: ResourceType) => void; + isResourceLoaded: (type: ResourceType) => boolean; + addTerminalSession: (session: { clusterId: string; namespace: string; pod: string; container: string; command: string }) => string; + removeTerminalSession: (sessionId: string) => void; + setGlobalSearchQuery: (query: string) => void; + setSearchResults: (type: ResourceType, results: ResourceInfo[]) => void; + addToBulkSelection: (type: ResourceType, resourceName: string) => void; + removeFromBulkSelection: (type: ResourceType, resourceName: string) => void; + clearBulkSelection: (type: ResourceType) => void; + getBulkSelectionCount: (type: ResourceType) => number; +} + +export const useKubernetesStore = create()((set, get) => ({ + // Selection state + selectedClusterId: null, + selectedNamespace: "all", + + // Data state + clusters: [], + contexts: [], + namespaces: {}, + + // Loaded resources tracking + loadedResources: new Set() as Set, + + // Terminal sessions + terminalSessions: {}, + nextTerminalId: 1, + + // Search state + globalSearchQuery: "", + searchResults: {} as Record, + + // Bulk selection + bulkSelection: {} as Record, + + // Actions + setSelectedCluster: (clusterId) => set({ selectedClusterId: clusterId, selectedNamespace: "all" }), + + setSelectedNamespace: (namespace) => set({ selectedNamespace: namespace }), + + addCluster: (cluster) => set((state) => ({ + clusters: [...state.clusters, cluster], + })), + + removeCluster: (clusterId) => set((state) => ({ + clusters: state.clusters.filter((c) => c.id !== clusterId), + selectedClusterId: state.selectedClusterId === clusterId ? null : state.selectedClusterId, + })), + + updateCluster: (clusterId, updates) => set((state) => ({ + clusters: state.clusters.map((c) => + c.id === clusterId ? { ...c, ...updates } : c + ), + })), + + addContext: (context) => set((state) => ({ + contexts: [...state.contexts, context], + })), + + setNamespaces: (clusterId, namespaces) => set((state) => ({ + namespaces: { ...state.namespaces, [clusterId]: namespaces }, + })), + + markResourceLoaded: (type) => set((state) => { + const newSet = new Set(state.loadedResources); + newSet.add(type); + return { loadedResources: newSet }; + }), + + markResourceUnloaded: (type) => set((state) => { + const newSet = new Set(state.loadedResources); + newSet.delete(type); + return { loadedResources: newSet }; + }), + + isResourceLoaded: (type) => get().loadedResources.has(type), + + addTerminalSession: (session) => { + const sessionId = `terminal-${get().nextTerminalId}`; + set((state) => ({ + terminalSessions: { ...state.terminalSessions, [sessionId]: { id: sessionId, ...session } }, + nextTerminalId: state.nextTerminalId + 1, + })); + return sessionId; + }, + + removeTerminalSession: (sessionId) => set((state) => ({ + terminalSessions: Object.fromEntries( + Object.entries(state.terminalSessions).filter(([id]) => id !== sessionId) + ), + })), + + setGlobalSearchQuery: (query) => set({ globalSearchQuery: query }), + + setSearchResults: (type, results) => set((state) => ({ + searchResults: { ...state.searchResults, [type]: results }, + })), + + addToBulkSelection: (type, resourceName) => set((state) => ({ + bulkSelection: { + ...state.bulkSelection, + [type]: [...(state.bulkSelection[type] || []), resourceName], + }, + })), + + removeFromBulkSelection: (type, resourceName) => set((state) => ({ + bulkSelection: { + ...state.bulkSelection, + [type]: (state.bulkSelection[type] || []).filter((name) => name !== resourceName), + }, + })), + + clearBulkSelection: (type) => set((state) => ({ + bulkSelection: { ...state.bulkSelection, [type]: [] }, + })), + + getBulkSelectionCount: (type) => (get().bulkSelection[type] || []).length, +})); diff --git a/tests/unit/kubernetesStore.test.ts b/tests/unit/kubernetesStore.test.ts new file mode 100644 index 00000000..1a125206 --- /dev/null +++ b/tests/unit/kubernetesStore.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useKubernetesStore } from "@/stores/kubernetesStore"; +import type { ResourceInfo } from "@/lib/tauriCommands"; + +describe("Kubernetes Store", () => { + beforeEach(() => { + useKubernetesStore.getState().clusters.forEach((c) => + useKubernetesStore.getState().removeCluster(c.id) + ); + }); + + describe("Cluster Management", () => { + it("should add a cluster", () => { + const cluster = { + id: "cluster-1", + name: "Production", + context: "prod-context", + cluster_url: "https://k8s.example.com", + }; + + useKubernetesStore.getState().addCluster(cluster); + + expect(useKubernetesStore.getState().clusters).toHaveLength(1); + expect(useKubernetesStore.getState().clusters[0].name).toBe("Production"); + }); + + it("should remove a cluster", () => { + const cluster = { + id: "cluster-1", + name: "Production", + context: "prod-context", + cluster_url: "https://k8s.example.com", + }; + + useKubernetesStore.getState().addCluster(cluster); + useKubernetesStore.getState().removeCluster("cluster-1"); + + expect(useKubernetesStore.getState().clusters).toHaveLength(0); + }); + + it("should update a cluster", () => { + const cluster = { + id: "cluster-1", + name: "Production", + context: "prod-context", + cluster_url: "https://k8s.example.com", + }; + + useKubernetesStore.getState().addCluster(cluster); + useKubernetesStore.getState().updateCluster("cluster-1", { name: "Production New" }); + + expect(useKubernetesStore.getState().clusters[0].name).toBe("Production New"); + }); + + it("should set selected cluster", () => { + const cluster = { + id: "cluster-1", + name: "Production", + context: "prod-context", + cluster_url: "https://k8s.example.com", + }; + + useKubernetesStore.getState().addCluster(cluster); + useKubernetesStore.getState().setSelectedCluster("cluster-1"); + + expect(useKubernetesStore.getState().selectedClusterId).toBe("cluster-1"); + }); + }); + + describe("Namespace Management", () => { + it("should set selected namespace", () => { + useKubernetesStore.getState().setSelectedNamespace("default"); + expect(useKubernetesStore.getState().selectedNamespace).toBe("default"); + }); + + it("should set namespaces for a cluster", () => { + useKubernetesStore.getState().setNamespaces("cluster-1", ["default", "kube-system", "production"]); + expect(useKubernetesStore.getState().namespaces["cluster-1"]).toEqual(["default", "kube-system", "production"]); + }); + }); + + describe("Resource Loading", () => { + it("should mark resource as loaded", () => { + useKubernetesStore.getState().markResourceLoaded("pods"); + expect(useKubernetesStore.getState().isResourceLoaded("pods")).toBe(true); + }); + + it("should mark resource as unloaded", () => { + useKubernetesStore.getState().markResourceLoaded("pods"); + useKubernetesStore.getState().markResourceUnloaded("pods"); + expect(useKubernetesStore.getState().isResourceLoaded("pods")).toBe(false); + }); + }); + + describe("Terminal Sessions", () => { + it("should add a terminal session", () => { + const sessionId = useKubernetesStore.getState().addTerminalSession({ + clusterId: "cluster-1", + namespace: "default", + pod: "nginx", + container: "nginx", + command: "bash", + }); + + expect(sessionId).toBe("terminal-1"); + expect(useKubernetesStore.getState().terminalSessions[sessionId]).toBeDefined(); + }); + + it("should remove a terminal session", () => { + const sessionId = useKubernetesStore.getState().addTerminalSession({ + clusterId: "cluster-1", + namespace: "default", + pod: "nginx", + container: "nginx", + command: "bash", + }); + + useKubernetesStore.getState().removeTerminalSession(sessionId); + expect(useKubernetesStore.getState().terminalSessions[sessionId]).toBeUndefined(); + }); + }); + + describe("Search", () => { + it("should set global search query", () => { + useKubernetesStore.getState().setGlobalSearchQuery("nginx"); + expect(useKubernetesStore.getState().globalSearchQuery).toBe("nginx"); + }); + + it("should set search results", () => { + const results = [{ name: "nginx-1", namespace: "default" }]; + useKubernetesStore.getState().setSearchResults("pods", results as ResourceInfo[]); + + expect(useKubernetesStore.getState().searchResults.pods).toEqual(results); + }); + }); + + describe("Bulk Selection", () => { + it("should add to bulk selection", () => { + useKubernetesStore.getState().addToBulkSelection("pods", "nginx-1"); + expect(useKubernetesStore.getState().bulkSelection.pods).toContain("nginx-1"); + }); + + it("should remove from bulk selection", () => { + useKubernetesStore.getState().addToBulkSelection("pods", "nginx-1"); + useKubernetesStore.getState().removeFromBulkSelection("pods", "nginx-1"); + expect(useKubernetesStore.getState().bulkSelection.pods).not.toContain("nginx-1"); + }); + + it("should clear bulk selection", () => { + useKubernetesStore.getState().addToBulkSelection("pods", "nginx-1"); + useKubernetesStore.getState().clearBulkSelection("pods"); + expect(useKubernetesStore.getState().bulkSelection.pods).toEqual([]); + }); + + it("should get bulk selection count", () => { + useKubernetesStore.getState().addToBulkSelection("pods", "nginx-1"); + useKubernetesStore.getState().addToBulkSelection("pods", "nginx-2"); + expect(useKubernetesStore.getState().getBulkSelectionCount("pods")).toBe(2); + }); + }); +});