diff --git a/src/components/Kubernetes/AttachModal.tsx b/src/components/Kubernetes/AttachModal.tsx new file mode 100644 index 00000000..2c241a8f --- /dev/null +++ b/src/components/Kubernetes/AttachModal.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui"; +import { Button } from "@/components/ui"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui"; +import { Alert, AlertDescription } from "@/components/ui"; +import { Link, Loader2 } from "lucide-react"; +import { attachPodCmd } from "@/lib/tauriCommands"; + +interface AttachModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + clusterId: string; + namespace: string; + podName: string; + containers: string[]; +} + +export function AttachModal({ + open, + onOpenChange, + clusterId, + namespace, + podName, + containers, +}: AttachModalProps) { + const [selectedContainer, setSelectedContainer] = React.useState(""); + const [output, setOutput] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (open) { + setSelectedContainer(containers[0] ?? ""); + setOutput(""); + setError(null); + } + }, [open, containers]); + + const handleAttach = async () => { + if (!selectedContainer) return; + setIsLoading(true); + setError(null); + try { + const result = await attachPodCmd(clusterId, namespace, podName, selectedContainer); + setOutput( + `Session ${result.session_id} — status: ${result.status}` + ); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + Attach — {podName} + + +
+
+ + +
+ {error && ( + + {error} + + )} +
+            {output || "Select a container and click Attach."}
+          
+
+
+
+ ); +} diff --git a/src/components/Kubernetes/ClusterRoleBindingList.tsx b/src/components/Kubernetes/ClusterRoleBindingList.tsx index 218349b3..7d39073e 100644 --- a/src/components/Kubernetes/ClusterRoleBindingList.tsx +++ b/src/components/Kubernetes/ClusterRoleBindingList.tsx @@ -1,41 +1,131 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { ClusterRoleBindingInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface ClusterRoleBindingListProps { clusterRoleBindings: ClusterRoleBindingInfo[]; - _clusterId: string; + clusterId?: string; + _clusterId?: string; + onRefresh?: () => void; } -export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: ClusterRoleBindingListProps) { +type ActiveModal = + | { type: "edit"; crb: ClusterRoleBindingInfo; yaml: string } + | { type: "delete"; crb: ClusterRoleBindingInfo } + | null; + +export function ClusterRoleBindingList({ + clusterRoleBindings, + clusterId, + _clusterId, + onRefresh, +}: ClusterRoleBindingListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (crb: ClusterRoleBindingInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(cid, "clusterrolebindings", "", crb.name); + setActiveModal({ type: "edit", crb, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsDeleting(true); + try { + await deleteResourceCmd(cid, "clusterrolebindings", "", activeModal.crb.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } + }; + return ( -
- - - - Name - Cluster Role - Age - - - - {clusterRoleBindings.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No cluster role bindings found - + Name + Cluster Role + Age + Actions - ) : ( - clusterRoleBindings.map((crb) => ( - - {crb.name} - {crb.cluster_role} - {crb.age} + + + {clusterRoleBindings.length === 0 ? ( + + + No cluster role bindings found + - )) - )} - -
-
+ ) : ( + clusterRoleBindings.map((crb) => ( + + {crb.name} + {crb.cluster_role} + {crb.age} + + openEdit(crb), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", crb }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="ClusterRoleBinding" + resourceName={activeModal.crb.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/ClusterRoleList.tsx b/src/components/Kubernetes/ClusterRoleList.tsx index 56f94c58..84e9a8d7 100644 --- a/src/components/Kubernetes/ClusterRoleList.tsx +++ b/src/components/Kubernetes/ClusterRoleList.tsx @@ -1,39 +1,129 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { ClusterRoleInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface ClusterRoleListProps { clusterRoles: ClusterRoleInfo[]; - _clusterId: string; + clusterId?: string; + _clusterId?: string; + onRefresh?: () => void; } -export function ClusterRoleList({ clusterRoles, _clusterId }: ClusterRoleListProps) { +type ActiveModal = + | { type: "edit"; cr: ClusterRoleInfo; yaml: string } + | { type: "delete"; cr: ClusterRoleInfo } + | null; + +export function ClusterRoleList({ + clusterRoles, + clusterId, + _clusterId, + onRefresh, +}: ClusterRoleListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (cr: ClusterRoleInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(cid, "clusterroles", "", cr.name); + setActiveModal({ type: "edit", cr, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsDeleting(true); + try { + await deleteResourceCmd(cid, "clusterroles", "", activeModal.cr.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } + }; + return ( -
- - - - Name - Age - - - - {clusterRoles.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No cluster roles found - + Name + Age + Actions - ) : ( - clusterRoles.map((clusterRole) => ( - - {clusterRole.name} - {clusterRole.age} + + + {clusterRoles.length === 0 ? ( + + + No cluster roles found + - )) - )} - -
-
+ ) : ( + clusterRoles.map((cr) => ( + + {cr.name} + {cr.age} + + openEdit(cr), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", cr }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="ClusterRole" + resourceName={activeModal.cr.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/ConfigMapList.tsx b/src/components/Kubernetes/ConfigMapList.tsx index 64ab1fe9..5f3cd5f9 100644 --- a/src/components/Kubernetes/ConfigMapList.tsx +++ b/src/components/Kubernetes/ConfigMapList.tsx @@ -1,57 +1,127 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; -import { Button } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { ConfigMapInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface ConfigMapListProps { configmaps: ConfigMapInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function ConfigMapList({ configmaps }: ConfigMapListProps) { +type ActiveModal = + | { type: "edit"; cm: ConfigMapInfo; yaml: string } + | { type: "delete"; cm: ConfigMapInfo } + | null; + +export function ConfigMapList({ configmaps, clusterId, namespace, onRefresh }: ConfigMapListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (cm: ConfigMapInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(clusterId, "configmaps", namespace, cm.name); + setActiveModal({ type: "edit", cm, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsDeleting(true); + try { + await deleteResourceCmd(clusterId, "configmaps", namespace, activeModal.cm.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } + }; return ( -
- - - - Name - Namespace - Data Keys - Age - Actions - - - - {configmaps.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No configmaps found - + Name + Namespace + Data Keys + Age + Actions - ) : ( - configmaps.map((configmap) => ( - - {configmap.name} - {configmap.namespace} - {configmap.data_keys} - {configmap.age} - - + + + {configmaps.length === 0 ? ( + + + No configmaps found - )) - )} - -
-
+ ) : ( + configmaps.map((cm) => ( + + {cm.name} + {cm.namespace} + {cm.data_keys} + {cm.age} + + openEdit(cm), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", cm }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="ConfigMap" + resourceName={activeModal.cm.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/ConfirmDeleteDialog.tsx b/src/components/Kubernetes/ConfirmDeleteDialog.tsx new file mode 100644 index 00000000..9e7dc36c --- /dev/null +++ b/src/components/Kubernetes/ConfirmDeleteDialog.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui"; +import { Button } from "@/components/ui"; +import { AlertTriangle, Loader2 } from "lucide-react"; + +interface ConfirmDeleteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + resourceType: string; + resourceName: string; + onConfirm: () => Promise | void; + isLoading?: boolean; + variant?: "delete" | "force-delete"; +} + +export function ConfirmDeleteDialog({ + open, + onOpenChange, + resourceType, + resourceName, + onConfirm, + isLoading = false, + variant = "delete", +}: ConfirmDeleteDialogProps) { + const isForce = variant === "force-delete"; + + const handleConfirm = async () => { + await onConfirm(); + }; + + return ( + + + + + + {isForce ? `Force Delete ${resourceType}` : `Delete ${resourceType}`} + + + {isForce ? ( + <> + Are you sure you want to force delete{" "} + {resourceName}? +
+ + This will immediately terminate the resource with no grace period. + + + ) : ( + <> + Are you sure you want to delete{" "} + {resourceName}? This + action cannot be undone. + + )} +
+
+ + + + +
+
+ ); +} diff --git a/src/components/Kubernetes/CrdList.tsx b/src/components/Kubernetes/CrdList.tsx new file mode 100644 index 00000000..a49f123c --- /dev/null +++ b/src/components/Kubernetes/CrdList.tsx @@ -0,0 +1,142 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { RefreshCw, ChevronRight, ChevronDown } from "lucide-react"; +import { Badge, Button } from "@/components/ui"; +import { listCrdsCmd } from "@/lib/tauriCommands"; +import type { CrdInfo } from "@/lib/tauriCommands"; +import { CustomResourceList } from "./CustomResourceList"; + +interface CrdListProps { + clusterId: string; + onSelectCrd?: (crd: CrdInfo) => void; +} + +function scopeVariant(scope: string): "default" | "secondary" { + return scope === "Namespaced" ? "default" : "secondary"; +} + +export function CrdList({ clusterId, onSelectCrd }: CrdListProps) { + const [crds, setCrds] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedCrd, setExpandedCrd] = useState(null); + + const loadCrds = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await listCrdsCmd(clusterId); + setCrds(data); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [clusterId]); + + useEffect(() => { + void loadCrds(); + }, [loadCrds]); + + const handleRowClick = (crd: CrdInfo) => { + const key = crd.name; + setExpandedCrd((prev) => (prev === key ? null : key)); + onSelectCrd?.(crd); + }; + + if (loading) { + return ( +
+ + Loading CRDs… +
+ ); + } + + return ( +
+
+ + {crds.length} custom resource definition{crds.length !== 1 ? "s" : ""} + + +
+ + {error && ( +
+ {error} +
+ )} + +
+ {crds.length === 0 ? ( +
+ No custom resource definitions found +
+ ) : ( + + + + + + + + + + + + + {crds.map((crd) => { + const isExpanded = expandedCrd === crd.name; + return ( + + handleRowClick(crd)} + > + + + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
NameKindGroupVersionScopeAge
+
+ {isExpanded ? ( + + ) : ( + + )} + {crd.name} +
+
{crd.kind}{crd.group}{crd.version} + + {crd.scope} + + {crd.age}
+ +
+ )} +
+
+ ); +} diff --git a/src/components/Kubernetes/CronJobList.tsx b/src/components/Kubernetes/CronJobList.tsx index 6570d5d4..8daeabb6 100644 --- a/src/components/Kubernetes/CronJobList.tsx +++ b/src/components/Kubernetes/CronJobList.tsx @@ -1,54 +1,206 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { PauseCircle, PlayCircle, Play, Pencil, Trash2 } from "lucide-react"; import type { CronJobInfo } from "@/lib/tauriCommands"; +import { + suspendCronjobCmd, + resumeCronjobCmd, + triggerCronjobCmd, + deleteResourceCmd, + getResourceYamlCmd, +} from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface CronJobListProps { cronJobs: CronJobInfo[]; - _clusterId: string; - _namespace: string; + clusterId?: string; + _clusterId?: string; + namespace?: string; + _namespace?: string; + onRefresh?: () => void; } -export function CronJobList({ cronJobs, _clusterId, _namespace }: CronJobListProps) { +type ActiveModal = + | { type: "edit"; cj: CronJobInfo; yaml: string } + | { type: "delete"; cj: CronJobInfo } + | null; + +export function CronJobList({ + cronJobs, + clusterId, + _clusterId, + namespace, + _namespace, + onRefresh, +}: CronJobListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const ns = namespace ?? _namespace ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (cj: CronJobInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(cid, "cronjobs", ns, cj.name); + setActiveModal({ type: "edit", cj, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleSuspend = async (cj: CronJobInfo) => { + setActionError(null); + try { + await suspendCronjobCmd(cid, ns, cj.name); + onRefresh?.(); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleResume = async (cj: CronJobInfo) => { + setActionError(null); + try { + await resumeCronjobCmd(cid, ns, cj.name); + onRefresh?.(); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleTrigger = async (cj: CronJobInfo) => { + setActionError(null); + try { + await triggerCronjobCmd(cid, ns, cj.name); + onRefresh?.(); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsDeleting(true); + try { + await deleteResourceCmd(cid, "cronjobs", ns, activeModal.cj.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } + }; + + const isSuspended = (cj: CronJobInfo) => { + const labels = cj.labels ?? {}; + return labels["cronjob.kubernetes.io/suspended"] === "true"; + }; + return ( -
- - - - Name - Namespace - Schedule - Active - Last Schedule - Age - Labels - - - - {cronJobs.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No cron jobs found - + Name + Namespace + Schedule + Active + Last Schedule + Age + Labels + Actions - ) : ( - 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(", ")} + + + {cronJobs.length === 0 ? ( + + + No cron jobs found - )) - )} - -
-
+ ) : ( + cronJobs.map((cj) => ( + + {cj.name} + {cj.namespace} + {cj.schedule} + {cj.active} + {cj.last_schedule} + {cj.age} + + {Object.entries(cj.labels) + .map(([k, v]) => `${k}=${v}`) + .join(", ")} + + + handleSuspend(cj), + }, + { + label: "Resume", + icon: PlayCircle, + hidden: !isSuspended(cj), + onClick: () => handleResume(cj), + }, + { + label: "Trigger", + icon: Play, + onClick: () => handleTrigger(cj), + }, + { + label: "Edit", + icon: Pencil, + onClick: () => openEdit(cj), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", cj }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="CronJob" + resourceName={activeModal.cj.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/CustomResourceList.tsx b/src/components/Kubernetes/CustomResourceList.tsx new file mode 100644 index 00000000..978ac559 --- /dev/null +++ b/src/components/Kubernetes/CustomResourceList.tsx @@ -0,0 +1,100 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { RefreshCw } from "lucide-react"; +import { listCustomResourcesCmd } from "@/lib/tauriCommands"; +import type { CustomResourceInfo } from "@/lib/tauriCommands"; + +interface CustomResourceListProps { + clusterId: string; + namespace: string; + group: string; + version: string; + resource: string; + kind: string; +} + +export function CustomResourceList({ + clusterId, + namespace, + group, + version, + resource, + kind, +}: CustomResourceListProps) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadItems = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await listCustomResourcesCmd(clusterId, group, version, resource, namespace); + setItems(data); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [clusterId, group, version, resource, namespace]); + + useEffect(() => { + void loadItems(); + }, [loadItems]); + + if (loading) { + return ( +
+ + Loading {kind} instances… +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (items.length === 0) { + return ( +

+ No {kind} instances found. +

+ ); + } + + const showNamespace = items.some((item) => item.namespace !== ""); + + return ( +
+ + + + + {showNamespace && ( + + )} + + + + + {items.map((item) => ( + + + {showNamespace && ( + + )} + + + ))} + +
NameNamespaceAge
{item.name}{item.namespace || "—"}{item.age}
+
+ ); +} diff --git a/src/components/Kubernetes/DaemonSetList.tsx b/src/components/Kubernetes/DaemonSetList.tsx index 317692d5..5c454426 100644 --- a/src/components/Kubernetes/DaemonSetList.tsx +++ b/src/components/Kubernetes/DaemonSetList.tsx @@ -1,50 +1,169 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { RotateCcw, Pencil, Trash2 } from "lucide-react"; import type { DaemonSetInfo } from "@/lib/tauriCommands"; +import { + restartDaemonsetCmd, + deleteResourceCmd, + getResourceYamlCmd, +} from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface DaemonSetListProps { daemonsets: DaemonSetInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _namespace }: DaemonSetListProps) { +type ActiveModal = + | { type: "restart"; ds: DaemonSetInfo } + | { type: "edit"; ds: DaemonSetInfo; yaml: string } + | { type: "delete"; ds: DaemonSetInfo } + | null; + +export function DaemonSetList({ daemonsets, clusterId, namespace, onRefresh }: DaemonSetListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isActing, setIsActing] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (ds: DaemonSetInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(clusterId, "daemonsets", namespace, ds.name); + setActiveModal({ type: "edit", ds, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleRestart = async () => { + if (activeModal?.type !== "restart") return; + setIsActing(true); + try { + await restartDaemonsetCmd(clusterId, namespace, activeModal.ds.name); + setActiveModal(null); + onRefresh?.(); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } finally { + setIsActing(false); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsActing(true); + try { + await deleteResourceCmd(clusterId, "daemonsets", namespace, activeModal.ds.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsActing(false); + } + }; + return ( -
- - - - Name - Desired - Current - Ready - Up-to-date - Available - Age - - - - {daemonsets.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No daemonsets found - + Name + Desired + Current + Ready + Up-to-date + Available + Age + Actions - ) : ( - daemonsets.map((ds) => ( - - {ds.name} - {ds.desired} - {ds.current} - {ds.ready} - {ds.up_to_date} - {ds.available} - {ds.age} + + + {daemonsets.length === 0 ? ( + + + No daemonsets found + - )) - )} - -
-
+ ) : ( + daemonsets.map((ds) => ( + + {ds.name} + {ds.desired} + {ds.current} + {ds.ready} + {ds.up_to_date} + {ds.available} + {ds.age} + + setActiveModal({ type: "restart", ds }), + }, + { + label: "Edit", + icon: Pencil, + onClick: () => openEdit(ds), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", ds }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "restart" && ( + { if (!o) setActiveModal(null); }} + resourceType="DaemonSet" + resourceName={activeModal.ds.name} + isLoading={isActing} + onConfirm={handleRestart} + variant="delete" + /> + )} + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="DaemonSet" + resourceName={activeModal.ds.name} + isLoading={isActing} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/DeploymentList.tsx b/src/components/Kubernetes/DeploymentList.tsx index a3407a35..9f535776 100644 --- a/src/components/Kubernetes/DeploymentList.tsx +++ b/src/components/Kubernetes/DeploymentList.tsx @@ -1,89 +1,94 @@ import React, { useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; 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 { Scale, RotateCcw, Undo2, Pencil, Trash2 } from "lucide-react"; import type { DeploymentInfo } from "@/lib/tauriCommands"; +import { + scaleDeploymentCmd, + restartDeploymentCmd, + rollbackDeploymentCmd, + deleteResourceCmd, + getResourceYamlCmd, +} from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { ScaleModal } from "./ScaleModal"; +import { EditResourceModal } from "./EditResourceModal"; interface DeploymentListProps { deployments: DeploymentInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function DeploymentList({ deployments, clusterId, namespace }: DeploymentListProps) { - const [scalingDeployment, setScalingDeployment] = useState(null); - const [replicas, setReplicas] = useState(""); - const [isScaling, setIsScaling] = useState(false); - const [scaleError, setScaleError] = useState(null); +type ActiveModal = + | { type: "scale"; deployment: DeploymentInfo } + | { type: "restart"; deployment: DeploymentInfo } + | { type: "rollback"; deployment: DeploymentInfo } + | { type: "edit"; deployment: DeploymentInfo; yaml: string } + | { type: "delete"; deployment: DeploymentInfo } + | null; - const [restartingDeployment, setRestartingDeployment] = useState(null); - const [isRestarting, setIsRestarting] = useState(false); - const [restartError, setRestartError] = useState(null); - - const handleScaleChange = (e: React.ChangeEvent) => { - 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); +export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: DeploymentListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isActing, setIsActing] = useState(false); + const [actionError, setActionError] = useState(null); + const openEdit = async (deployment: DeploymentInfo) => { + setActionError(null); try { - await invoke("scale_deployment", { - clusterId, - namespace, - deploymentName: scalingDeployment.name, - replicas: newReplicas, - }); - - setScalingDeployment(null); - setReplicas(""); + const yaml = await getResourceYamlCmd(clusterId, "deployments", namespace, deployment.name); + setActiveModal({ type: "edit", deployment, yaml }); } catch (err) { - console.error("Failed to scale deployment:", err); - setScaleError(err instanceof Error ? err.message : "Failed to scale deployment"); - } finally { - setIsScaling(false); + setActionError(err instanceof Error ? err.message : String(err)); } }; - const handleRestartSubmit = async () => { - if (!restartingDeployment) return; - - setIsRestarting(true); - setRestartError(null); - + const handleRestart = async () => { + if (activeModal?.type !== "restart") return; + setIsActing(true); try { - await invoke("restart_deployment", { - clusterId, - namespace, - deploymentName: restartingDeployment.name, - }); - - setRestartingDeployment(null); + await restartDeploymentCmd(clusterId, namespace, activeModal.deployment.name); + setActiveModal(null); + onRefresh?.(); } catch (err) { - console.error("Failed to restart deployment:", err); - setRestartError(err instanceof Error ? err.message : "Failed to restart deployment"); + setActionError(err instanceof Error ? err.message : String(err)); } finally { - setIsRestarting(false); + setIsActing(false); + } + }; + + const handleRollback = async () => { + if (activeModal?.type !== "rollback") return; + setIsActing(true); + try { + await rollbackDeploymentCmd(clusterId, namespace, activeModal.deployment.name); + setActiveModal(null); + onRefresh?.(); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } finally { + setIsActing(false); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsActing(true); + try { + await deleteResourceCmd(clusterId, "deployments", namespace, activeModal.deployment.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsActing(false); } }; return ( <> + {actionError && ( +

{actionError}

+ )}
@@ -114,24 +119,36 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment {deployment.replicas} {deployment.age} -
- - -
+ setActiveModal({ type: "scale", deployment }), + }, + { + label: "Restart", + icon: RotateCcw, + onClick: () => setActiveModal({ type: "restart", deployment }), + }, + { + label: "Rollback", + icon: Undo2, + onClick: () => setActiveModal({ type: "rollback", deployment }), + }, + { + label: "Edit", + icon: Pencil, + onClick: () => openEdit(deployment), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", deployment }), + }, + ]} + />
)) @@ -140,69 +157,68 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment
- {/* Scale Dialog */} - setScalingDeployment(null)}> - - - Scale Deployment - -
-
- - - {scaleError && ( - - - {scaleError} - - )} -
-
- - - - -
-
+ {activeModal?.type === "scale" && ( + { if (!o) setActiveModal(null); }} + resourceType="Deployment" + resourceName={activeModal.deployment.name} + currentReplicas={activeModal.deployment.replicas} + onScale={(replicas) => + scaleDeploymentCmd(clusterId, namespace, activeModal.deployment.name, replicas).then(() => { + setActiveModal(null); + onRefresh?.(); + }) + } + /> + )} - {/* Restart Dialog */} - setRestartingDeployment(null)}> - - - Restart Deployment - -
-

- This will trigger a rolling restart of the deployment. -

- {restartError && ( - - - {restartError} - - )} -
- - - - -
-
+ {activeModal?.type === "restart" && ( + { if (!o) setActiveModal(null); }} + resourceType="Deployment" + resourceName={activeModal.deployment.name} + isLoading={isActing} + onConfirm={handleRestart} + variant="delete" + /> + )} + + {activeModal?.type === "rollback" && ( + { if (!o) setActiveModal(null); }} + resourceType="Deployment" + resourceName={activeModal.deployment.name} + isLoading={isActing} + onConfirm={handleRollback} + variant="delete" + /> + )} + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="Deployment" + resourceName={activeModal.deployment.name} + isLoading={isActing} + onConfirm={handleDelete} + /> + )} ); } diff --git a/src/components/Kubernetes/EndpointList.tsx b/src/components/Kubernetes/EndpointList.tsx new file mode 100644 index 00000000..f13f47f7 --- /dev/null +++ b/src/components/Kubernetes/EndpointList.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { EndpointInfo } from "@/lib/tauriCommands"; + +interface EndpointListProps { + items: EndpointInfo[]; + clusterId: string; + namespace?: string; +} + +export function EndpointList({ items }: EndpointListProps) { + return ( +
+ + + + Name + Namespace + Addresses + Ports + Age + + + + {items.length === 0 ? ( + + + No endpoints found + + + ) : ( + items.map((ep) => ( + + {ep.name} + {ep.namespace} + + {ep.addresses.length > 0 ? ep.addresses.join(", ") : "—"} + + + {ep.ports.length > 0 ? ep.ports.join(", ") : "—"} + + {ep.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/EndpointSliceList.tsx b/src/components/Kubernetes/EndpointSliceList.tsx new file mode 100644 index 00000000..c55833a3 --- /dev/null +++ b/src/components/Kubernetes/EndpointSliceList.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { EndpointSliceInfo } from "@/lib/tauriCommands"; + +interface EndpointSliceListProps { + items: EndpointSliceInfo[]; + clusterId: string; + namespace?: string; +} + +export function EndpointSliceList({ items }: EndpointSliceListProps) { + return ( +
+ + + + Name + Namespace + Address Type + Endpoints + Ports + Age + + + + {items.length === 0 ? ( + + + No endpoint slices found + + + ) : ( + items.map((eps) => ( + + {eps.name} + {eps.namespace} + {eps.address_type} + {eps.endpoints} + + {eps.ports.length > 0 ? eps.ports.join(", ") : "—"} + + {eps.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/HPAList.tsx b/src/components/Kubernetes/HPAList.tsx index d486106d..ec2de8db 100644 --- a/src/components/Kubernetes/HPAList.tsx +++ b/src/components/Kubernetes/HPAList.tsx @@ -1,50 +1,144 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { HorizontalPodAutoscalerInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface HPAListProps { hpas: HorizontalPodAutoscalerInfo[]; - _clusterId: string; - _namespace: string; + clusterId?: string; + _clusterId?: string; + namespace?: string; + _namespace?: string; + onRefresh?: () => void; } -export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) { +type ActiveModal = + | { type: "edit"; hpa: HorizontalPodAutoscalerInfo; yaml: string } + | { type: "delete"; hpa: HorizontalPodAutoscalerInfo } + | null; + +export function HPAList({ + hpas, + clusterId, + _clusterId, + namespace, + _namespace, + onRefresh, +}: HPAListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const ns = namespace ?? _namespace ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (hpa: HorizontalPodAutoscalerInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(cid, "horizontalpodautoscalers", ns, hpa.name); + setActiveModal({ type: "edit", hpa, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsDeleting(true); + try { + await deleteResourceCmd(cid, "horizontalpodautoscalers", ns, activeModal.hpa.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } + }; + return ( -
- - - - Name - Namespace - Min Replicas - Max Replicas - Current Replicas - Desired Replicas - Age - - - - {hpas.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No HPAs found - + Name + Namespace + Min Replicas + Max Replicas + Current Replicas + Desired Replicas + Age + Actions - ) : ( - hpas.map((hpa) => ( - - {hpa.name} - {hpa.namespace} - {hpa.min_replicas} - {hpa.max_replicas} - {hpa.current_replicas} - {hpa.desired_replicas} - {hpa.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} + + openEdit(hpa), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", hpa }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="HPA" + resourceName={activeModal.hpa.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/HelmChartList.tsx b/src/components/Kubernetes/HelmChartList.tsx new file mode 100644 index 00000000..c38115c5 --- /dev/null +++ b/src/components/Kubernetes/HelmChartList.tsx @@ -0,0 +1,296 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Plus, RefreshCw, Search, ChevronDown, ChevronRight } from "lucide-react"; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + Input, + Label, + Badge, +} from "@/components/ui"; +import { + helmListReposCmd, + helmSearchRepoCmd, + helmAddRepoCmd, + helmUpdateReposCmd, +} from "@/lib/tauriCommands"; +import type { HelmRepository, HelmChart } from "@/lib/tauriCommands"; + +interface HelmChartListProps { + clusterId: string; +} + +export function HelmChartList({ clusterId }: HelmChartListProps) { + const [repos, setRepos] = useState([]); + const [charts, setCharts] = useState([]); + const [selectedRepo, setSelectedRepo] = useState(null); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(false); + const [updatingRepos, setUpdatingRepos] = useState(false); + const [error, setError] = useState(null); + const [expandedChart, setExpandedChart] = useState(null); + + const [addRepoOpen, setAddRepoOpen] = useState(false); + const [newRepoName, setNewRepoName] = useState(""); + const [newRepoUrl, setNewRepoUrl] = useState(""); + const [addingRepo, setAddingRepo] = useState(false); + const [addRepoError, setAddRepoError] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const repoList = await helmListReposCmd(clusterId); + setRepos(repoList); + const chartList = await helmSearchRepoCmd(clusterId, ""); + setCharts(chartList); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [clusterId]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + const handleUpdateRepos = async () => { + setUpdatingRepos(true); + setError(null); + try { + await helmUpdateReposCmd(clusterId); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setUpdatingRepos(false); + } + }; + + const handleAddRepo = async () => { + if (!newRepoName.trim() || !newRepoUrl.trim()) return; + setAddingRepo(true); + setAddRepoError(null); + try { + await helmAddRepoCmd(clusterId, newRepoName.trim(), newRepoUrl.trim()); + setAddRepoOpen(false); + setNewRepoName(""); + setNewRepoUrl(""); + await loadData(); + } catch (err) { + setAddRepoError(err instanceof Error ? err.message : String(err)); + } finally { + setAddingRepo(false); + } + }; + + const filteredCharts = charts.filter((c) => { + const matchesRepo = selectedRepo == null || c.repository === selectedRepo; + const matchesSearch = + search.trim() === "" || + c.name.toLowerCase().includes(search.toLowerCase()) || + c.description.toLowerCase().includes(search.toLowerCase()); + return matchesRepo && matchesSearch; + }); + + return ( +
+ {/* Toolbar */} +
+ + +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Repository sidebar */} +
+
+ Repositories +
+
setSelectedRepo(null)} + > + All repositories +
+ {repos.map((repo) => ( +
setSelectedRepo(repo.name)} + > + {repo.name} +
+ ))} + {repos.length === 0 && !loading && ( +
No repos
+ )} +
+ + {/* Charts table */} +
+ {loading ? ( +
+ + Loading charts… +
+ ) : repos.length === 0 ? ( +
+

No helm repositories configured.

+

Add a repository to get started.

+
+ ) : filteredCharts.length === 0 ? ( +
+ No charts match your search. +
+ ) : ( + + + + + + + + + + + + {filteredCharts.map((chart) => { + const key = `${chart.repository}/${chart.name}`; + const isExpanded = expandedChart === key; + return ( + + setExpandedChart(isExpanded ? null : key)} + > + + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
NameVersionApp VersionRepositoryDescription
+
+ {isExpanded ? ( + + ) : ( + + )} + {chart.name.includes("/") ? chart.name.split("/").slice(1).join("/") : chart.name} +
+
{chart.chart_version}{chart.app_version || "—"} + + {chart.repository} + + + {chart.description || "—"} +
+
+
+ {chart.repository}/{chart.name} +
+
{chart.description || "No description available."}
+
+ Chart: {chart.chart_version} + {chart.app_version && App: {chart.app_version}} +
+
+
+ )} +
+
+ + {/* Add Repository Dialog */} + + + + Add Helm Repository + +
+
+ + setNewRepoName(e.target.value)} + /> +
+
+ + setNewRepoUrl(e.target.value)} + /> +
+ {addRepoError && ( +
+ {addRepoError} +
+ )} +
+ + + + +
+
+
+ ); +} diff --git a/src/components/Kubernetes/HelmReleaseList.tsx b/src/components/Kubernetes/HelmReleaseList.tsx new file mode 100644 index 00000000..78deae87 --- /dev/null +++ b/src/components/Kubernetes/HelmReleaseList.tsx @@ -0,0 +1,262 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { MoreHorizontal, RefreshCw } from "lucide-react"; +import { + Button, + Badge, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui"; +import { helmListReleasesCmd, helmRollbackCmd, helmUninstallCmd } from "@/lib/tauriCommands"; +import type { HelmRelease } from "@/lib/tauriCommands"; + +interface HelmReleaseListProps { + clusterId: string; + namespace: string; +} + +type ConfirmAction = + | { type: "rollback"; release: HelmRelease } + | { type: "uninstall"; release: HelmRelease }; + +function statusVariant( + status: string +): "success" | "destructive" | "secondary" | "default" { + switch (status.toLowerCase()) { + case "deployed": + return "success"; + case "failed": + return "destructive"; + case "pending-install": + case "pending-upgrade": + case "pending-rollback": + return "default"; + case "superseded": + return "secondary"; + default: + return "secondary"; + } +} + +function statusLabel(status: string): string { + return status + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +export function HelmReleaseList({ clusterId, namespace }: HelmReleaseListProps) { + const [releases, setReleases] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [openMenuId, setOpenMenuId] = useState(null); + const [confirmAction, setConfirmAction] = useState(null); + const [actionInProgress, setActionInProgress] = useState(false); + const [actionError, setActionError] = useState(null); + + const loadReleases = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await helmListReleasesCmd(clusterId, namespace); + setReleases(data); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [clusterId, namespace]); + + useEffect(() => { + void loadReleases(); + }, [loadReleases]); + + const handleConfirm = async () => { + if (!confirmAction) return; + setActionInProgress(true); + setActionError(null); + try { + const { release } = confirmAction; + if (confirmAction.type === "rollback") { + await helmRollbackCmd(clusterId, release.namespace, release.name); + } else { + await helmUninstallCmd(clusterId, release.namespace, release.name); + setReleases((prev) => prev.filter((r) => r.name !== release.name)); + } + setConfirmAction(null); + if (confirmAction.type === "rollback") { + await loadReleases(); + } + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } finally { + setActionInProgress(false); + } + }; + + if (loading) { + return ( +
+ + Loading releases… +
+ ); + } + + return ( +
+
+ + {releases.length} release{releases.length !== 1 ? "s" : ""} + + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + Name + Namespace + Chart + Chart Version + App Version + Status + Updated + + + + + {releases.length === 0 ? ( + + + No releases found + + + ) : ( + releases.map((release) => { + const menuKey = `${release.namespace}/${release.name}`; + return ( + + {release.name} + {release.namespace} + {release.chart} + {release.chart_version} + {release.app_version || "—"} + + + {statusLabel(release.status)} + + + {release.updated} + +
+ + {openMenuId === menuKey && ( +
setOpenMenuId(null)} + > + + +
+ )} +
+
+
+ ); + }) + )} +
+
+
+ + {/* Confirm dialog */} + { if (!o) setConfirmAction(null); }}> + + + + {confirmAction?.type === "rollback" ? "Rollback Release" : "Uninstall Release"} + + +

+ {confirmAction?.type === "rollback" ? ( + <> + Roll back {confirmAction.release.name} to the + previous revision? This cannot be undone without a re-deploy. + + ) : ( + <> + Permanently uninstall {confirmAction?.release.name}? + All Kubernetes resources created by this release will be removed. + + )} +

+ {actionError && ( +
+ {actionError} +
+ )} + + + + +
+
+
+ ); +} diff --git a/src/components/Kubernetes/IngressClassList.tsx b/src/components/Kubernetes/IngressClassList.tsx new file mode 100644 index 00000000..383efe4b --- /dev/null +++ b/src/components/Kubernetes/IngressClassList.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge } from "@/components/ui"; +import type { IngressClassInfo } from "@/lib/tauriCommands"; + +interface IngressClassListProps { + items: IngressClassInfo[]; + clusterId: string; + namespace?: string; +} + +export function IngressClassList({ items }: IngressClassListProps) { + return ( +
+ + + + Name + Controller + Default + Age + + + + {items.length === 0 ? ( + + + No ingress classes found + + + ) : ( + items.map((ic) => ( + + {ic.name} + {ic.controller} + + {ic.is_default ? ( + Yes + ) : ( + No + )} + + {ic.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/IngressList.tsx b/src/components/Kubernetes/IngressList.tsx index 59fee670..93ae5cf7 100644 --- a/src/components/Kubernetes/IngressList.tsx +++ b/src/components/Kubernetes/IngressList.tsx @@ -1,48 +1,142 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { IngressInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface IngressListProps { ingresses: IngressInfo[]; - _clusterId: string; - _namespace: string; + clusterId?: string; + _clusterId?: string; + namespace?: string; + _namespace?: string; + onRefresh?: () => void; } -export function IngressList({ ingresses, _clusterId, _namespace }: IngressListProps) { +type ActiveModal = + | { type: "edit"; ingress: IngressInfo; yaml: string } + | { type: "delete"; ingress: IngressInfo } + | null; + +export function IngressList({ + ingresses, + clusterId, + _clusterId, + namespace, + _namespace, + onRefresh, +}: IngressListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const ns = namespace ?? _namespace ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (ingress: IngressInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(cid, "ingresses", ns, ingress.name); + setActiveModal({ type: "edit", ingress, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsDeleting(true); + try { + await deleteResourceCmd(cid, "ingresses", ns, activeModal.ingress.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } + }; + return ( -
- - - - Name - Namespace - Class - Host - Addresses - Age - - - - {ingresses.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No ingresses found - + Name + Namespace + Class + Host + Addresses + Age + Actions - ) : ( - ingresses.map((ingress) => ( - - {ingress.name} - {ingress.namespace} - {ingress.class || "-"} - {ingress.host} - {ingress.addresses.join(", ")} - {ingress.age} + + + {ingresses.length === 0 ? ( + + + No ingresses found + - )) - )} - -
-
+ ) : ( + ingresses.map((ingress) => ( + + {ingress.name} + {ingress.namespace} + {ingress.class || "-"} + {ingress.host} + {ingress.addresses.join(", ")} + {ingress.age} + + openEdit(ingress), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", ingress }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="Ingress" + resourceName={activeModal.ingress.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/JobList.tsx b/src/components/Kubernetes/JobList.tsx index f589d3ce..226b21d3 100644 --- a/src/components/Kubernetes/JobList.tsx +++ b/src/components/Kubernetes/JobList.tsx @@ -1,52 +1,146 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { JobInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface JobListProps { jobs: JobInfo[]; - _clusterId: string; - _namespace: string; + clusterId?: string; + _clusterId?: string; + namespace?: string; + _namespace?: string; + onRefresh?: () => void; } -export function JobList({ jobs, _clusterId, _namespace }: JobListProps) { +type ActiveModal = + | { type: "edit"; job: JobInfo; yaml: string } + | { type: "delete"; job: JobInfo } + | null; + +export function JobList({ + jobs, + clusterId, + _clusterId, + namespace, + _namespace, + onRefresh, +}: JobListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const ns = namespace ?? _namespace ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (job: JobInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(cid, "jobs", ns, job.name); + setActiveModal({ type: "edit", job, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsDeleting(true); + try { + await deleteResourceCmd(cid, "jobs", ns, activeModal.job.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } + }; + return ( -
- - - - Name - Namespace - Completions - Duration - Age - Labels - - - - {jobs.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No jobs found - + Name + Namespace + Completions + Duration + Age + Labels + Actions - ) : ( - jobs.map((job) => ( - - {job.name} - {job.namespace} - {job.completions} - {job.duration} - {job.age} - - {Object.entries(job.labels) - .map(([k, v]) => `${k}=${v}`) - .join(", ")} + + + {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(", ")} + + + openEdit(job), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", job }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="Job" + resourceName={activeModal.job.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/LeaseList.tsx b/src/components/Kubernetes/LeaseList.tsx new file mode 100644 index 00000000..797cd409 --- /dev/null +++ b/src/components/Kubernetes/LeaseList.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { LeaseInfo } from "@/lib/tauriCommands"; + +interface LeaseListProps { + items: LeaseInfo[]; + clusterId: string; + namespace?: string; +} + +export function LeaseList({ items }: LeaseListProps) { + return ( +
+ + + + Name + Namespace + Holder + Age + + + + {items.length === 0 ? ( + + + No leases found + + + ) : ( + items.map((lease) => ( + + {lease.name} + {lease.namespace} + {lease.holder || "—"} + {lease.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/LimitRangeList.tsx b/src/components/Kubernetes/LimitRangeList.tsx index f1328882..c64bbfcc 100644 --- a/src/components/Kubernetes/LimitRangeList.tsx +++ b/src/components/Kubernetes/LimitRangeList.tsx @@ -1,44 +1,127 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { LimitRangeInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface LimitRangeListProps { limitranges: LimitRangeInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function LimitRangeList({ limitranges }: LimitRangeListProps) { +type ActiveModal = + | { type: "edit"; lr: LimitRangeInfo; yaml: string } + | { type: "delete"; lr: LimitRangeInfo } + | null; + +export function LimitRangeList({ limitranges, clusterId, namespace, onRefresh }: LimitRangeListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (lr: LimitRangeInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(clusterId, "limitranges", namespace, lr.name); + setActiveModal({ type: "edit", lr, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsDeleting(true); + try { + await deleteResourceCmd(clusterId, "limitranges", namespace, activeModal.lr.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } + }; + return ( -
- - - - Name - Namespace - Limits - Age - - - - {limitranges.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No limit ranges found - + Name + Namespace + Limits + Age + Actions - ) : ( - limitranges.map((lr) => ( - - {lr.name} - {lr.namespace} - {lr.limit_count} - {lr.age} + + + {limitranges.length === 0 ? ( + + + No limit ranges found + - )) - )} - -
-
+ ) : ( + limitranges.map((lr) => ( + + {lr.name} + {lr.namespace} + {lr.limit_count} + {lr.age} + + openEdit(lr), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", lr }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="LimitRange" + resourceName={activeModal.lr.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/LogStreamPanel.tsx b/src/components/Kubernetes/LogStreamPanel.tsx new file mode 100644 index 00000000..843c1b5d --- /dev/null +++ b/src/components/Kubernetes/LogStreamPanel.tsx @@ -0,0 +1,294 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { Download, Search, Square, Trash2, Play } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Button, + Input, +} from "@/components/ui"; +import { streamPodLogsCmd, stopLogStreamCmd } from "@/lib/tauriCommands"; + +interface LogStreamPanelProps { + clusterId: string; + namespace: string; + podName: string; + containers: string[]; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const MAX_LINES = 5000; + +export function LogStreamPanel({ + clusterId, + namespace, + podName, + containers, + open, + onOpenChange, +}: LogStreamPanelProps) { + const [selectedContainer, setSelectedContainer] = useState( + containers[0] ?? "" + ); + const [follow, setFollow] = useState(true); + const [timestamps, setTimestamps] = useState(false); + const [tailLines, setTailLines] = useState(100); + const [lines, setLines] = useState([]); + const [streaming, setStreaming] = useState(false); + const [search, setSearch] = useState(""); + const [error, setError] = useState(null); + + const streamIdRef = useRef(null); + const unlistenRef = useRef(null); + const bottomRef = useRef(null); + + const stopStream = useCallback(async () => { + if (unlistenRef.current) { + unlistenRef.current(); + unlistenRef.current = null; + } + if (streamIdRef.current) { + try { + await stopLogStreamCmd(streamIdRef.current); + } catch { + // best-effort + } + streamIdRef.current = null; + } + setStreaming(false); + }, []); + + useEffect(() => { + if (!open) { + void stopStream(); + } + }, [open, stopStream]); + + useEffect(() => { + return () => { + void stopStream(); + }; + }, [stopStream]); + + useEffect(() => { + if (follow && streaming && bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [lines, follow, streaming]); + + const startStream = async () => { + if (streaming) return; + setError(null); + setLines([]); + + try { + const streamId = await streamPodLogsCmd({ + cluster_id: clusterId, + namespace, + pod_name: podName, + container_name: selectedContainer, + follow, + timestamps, + tail_lines: tailLines, + }); + + streamIdRef.current = streamId; + + const unlisten = await listen<{ stream_id: string; line: string }>( + "pod-log-line", + (event) => { + if (event.payload.stream_id !== streamId) return; + setLines((prev) => { + const next = [...prev, event.payload.line]; + return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next; + }); + } + ); + + unlistenRef.current = unlisten; + setStreaming(true); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDownload = () => { + const content = lines.join("\n"); + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${podName}-${selectedContainer}-logs.txt`; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleClear = () => { + setLines([]); + }; + + const filteredLines = + search.trim() === "" ? lines : lines.filter((l) => l.includes(search)); + + const displayLines = search.trim() !== "" ? filteredLines : lines; + + return ( + + + + + Log Stream — {podName} + + + +
+ {/* Controls row */} +
+ + + + + + +
+ Tail lines: + + setTailLines(Math.min(10000, Math.max(10, Number(e.target.value)))) + } + className="flex h-9 w-24 rounded-md border border-input bg-background px-3 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ +
+ {!streaming ? ( + + ) : ( + + )} + + +
+
+ + {/* Search bar */} +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + {error && ( +
+ {error} +
+ )} + + {/* Log output */} +
+ {displayLines.length === 0 ? ( + + {streaming ? "Waiting for log data…" : "No logs to display. Press Stream to begin."} + + ) : ( + <> + {(search.trim() !== "" ? lines : displayLines).map((line, i) => { + const matches = search.trim() !== "" && line.includes(search); + const visible = search.trim() === "" || matches; + return ( +
+ {matches && search.trim() !== "" ? ( + highlightMatch(line, search) + ) : ( + line + )} +
+ ); + })} +
+ + )} +
+ +
+ {lines.length.toLocaleString()} line{lines.length !== 1 ? "s" : ""} + {search.trim() !== "" && ` — ${filteredLines.length.toLocaleString()} matching`} +
+
+ +
+ ); +} + +function highlightMatch(line: string, search: string): React.ReactNode { + const idx = line.indexOf(search); + if (idx === -1) return line; + return ( + <> + {line.slice(0, idx)} + {line.slice(idx, idx + search.length)} + {line.slice(idx + search.length)} + + ); +} diff --git a/src/components/Kubernetes/LogsModal.tsx b/src/components/Kubernetes/LogsModal.tsx new file mode 100644 index 00000000..a5811b90 --- /dev/null +++ b/src/components/Kubernetes/LogsModal.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui"; +import { Button } from "@/components/ui"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui"; +import { Alert, AlertDescription } from "@/components/ui"; +import { FileText, Loader2 } from "lucide-react"; +import { getPodLogsCmd } from "@/lib/tauriCommands"; + +interface LogsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + clusterId: string; + namespace: string; + podName: string; + containers: string[]; +} + +export function LogsModal({ + open, + onOpenChange, + clusterId, + namespace, + podName, + containers, +}: LogsModalProps) { + const [selectedContainer, setSelectedContainer] = React.useState(""); + const [logs, setLogs] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (open) { + setSelectedContainer(containers[0] ?? ""); + setLogs(""); + setError(null); + } + }, [open, containers]); + + const fetchLogs = async () => { + if (!selectedContainer) return; + setIsLoading(true); + setError(null); + try { + const response = await getPodLogsCmd(clusterId, namespace, podName, selectedContainer); + setLogs(response.logs); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + Logs — {podName} + + +
+
+ + +
+ {error && ( + + {error} + + )} +
+            {logs || "No logs. Select a container and click Fetch Logs."}
+          
+
+
+
+ ); +} diff --git a/src/components/Kubernetes/MutatingWebhookList.tsx b/src/components/Kubernetes/MutatingWebhookList.tsx new file mode 100644 index 00000000..9aa7ae9a --- /dev/null +++ b/src/components/Kubernetes/MutatingWebhookList.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { WebhookConfigInfo } from "@/lib/tauriCommands"; + +interface MutatingWebhookListProps { + items: WebhookConfigInfo[]; + clusterId: string; + namespace?: string; +} + +export function MutatingWebhookList({ items }: MutatingWebhookListProps) { + return ( +
+ + + + Name + Webhooks + Age + + + + {items.length === 0 ? ( + + + No mutating webhook configurations found + + + ) : ( + items.map((wh) => ( + + {wh.name} + {wh.webhooks} + {wh.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/NamespaceList.tsx b/src/components/Kubernetes/NamespaceList.tsx new file mode 100644 index 00000000..0a961209 --- /dev/null +++ b/src/components/Kubernetes/NamespaceList.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge } from "@/components/ui"; +import type { NamespaceResourceInfo } from "@/lib/tauriCommands"; + +interface NamespaceListProps { + items: NamespaceResourceInfo[]; + clusterId: string; + namespace?: string; +} + +function statusVariant(status: string): "success" | "destructive" | "secondary" { + if (status === "Active") return "success"; + if (status === "Terminating") return "destructive"; + return "secondary"; +} + +export function NamespaceList({ items }: NamespaceListProps) { + return ( +
+ + + + Name + Status + Age + + + + {items.length === 0 ? ( + + + No namespaces found + + + ) : ( + items.map((ns) => ( + + {ns.name} + + {ns.status} + + {ns.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/NetworkPolicyList.tsx b/src/components/Kubernetes/NetworkPolicyList.tsx index 234e5679..cbb0fe52 100644 --- a/src/components/Kubernetes/NetworkPolicyList.tsx +++ b/src/components/Kubernetes/NetworkPolicyList.tsx @@ -1,46 +1,129 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { NetworkPolicyInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface NetworkPolicyListProps { networkpolicies: NetworkPolicyInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function NetworkPolicyList({ networkpolicies }: NetworkPolicyListProps) { +type ActiveModal = + | { type: "edit"; np: NetworkPolicyInfo; yaml: string } + | { type: "delete"; np: NetworkPolicyInfo } + | null; + +export function NetworkPolicyList({ networkpolicies, clusterId, namespace, onRefresh }: NetworkPolicyListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (np: NetworkPolicyInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(clusterId, "networkpolicies", namespace, np.name); + setActiveModal({ type: "edit", np, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsDeleting(true); + try { + await deleteResourceCmd(clusterId, "networkpolicies", namespace, activeModal.np.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } + }; + return ( -
- - - - Name - Namespace - Pod Selector - Policy Types - Age - - - - {networkpolicies.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No network policies found - + Name + Namespace + Pod Selector + Policy Types + Age + Actions - ) : ( - networkpolicies.map((np) => ( - - {np.name} - {np.namespace} - {np.pod_selector} - {np.policy_types.join(", ") || "—"} - {np.age} + + + {networkpolicies.length === 0 ? ( + + + No network policies found + - )) - )} - -
-
+ ) : ( + networkpolicies.map((np) => ( + + {np.name} + {np.namespace} + {np.pod_selector} + {np.policy_types.join(", ") || "—"} + {np.age} + + openEdit(np), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", np }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="NetworkPolicy" + resourceName={activeModal.np.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/NodeList.tsx b/src/components/Kubernetes/NodeList.tsx index ad2b68be..77a0097b 100644 --- a/src/components/Kubernetes/NodeList.tsx +++ b/src/components/Kubernetes/NodeList.tsx @@ -1,24 +1,33 @@ 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 { ShieldOff, ShieldCheck, Trash2, Pencil } from "lucide-react"; import type { NodeInfo } from "@/lib/tauriCommands"; +import { + cordonNodeCmd, + uncordonNodeCmd, + drainNodeCmd, + getResourceYamlCmd, +} from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface NodeListProps { nodes: NodeInfo[]; clusterId: string; + onRefresh?: () => void; } -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); +type ActiveModal = + | { type: "drain"; node: NodeInfo } + | { type: "edit"; node: NodeInfo; yaml: string } + | null; + +export function NodeList({ nodes, clusterId, onRefresh }: NodeListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isActing, setIsActing] = useState(false); + const [actionError, setActionError] = useState(null); const getNodeStatusColor = (status: string) => { switch (status.toLowerCase()) { @@ -33,53 +42,59 @@ export function NodeList({ nodes, clusterId }: NodeListProps) { } }; - const handleCordon = async () => { - if (!selectedNode) return; - - setIsCordoning(true); - setError(null); + const isSchedulingDisabled = (node: NodeInfo) => + node.status.toLowerCase().includes("schedulingdisabled") || + node.roles.toLowerCase().includes("schedulingdisabled"); + + const handleCordon = async (node: NodeInfo) => { + setActionError(null); try { - await invoke("cordon_node", { clusterId, nodeName: selectedNode.name }); - setSelectedNode(null); + await cordonNodeCmd(clusterId, node.name); + onRefresh?.(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to cordon node"); - } finally { - setIsCordoning(false); + setActionError(err instanceof Error ? err.message : String(err)); } }; - const handleUncordon = async () => { - if (!selectedNode) return; - - setIsUncordoning(true); - setError(null); + const handleUncordon = async (node: NodeInfo) => { + setActionError(null); try { - await invoke("uncordon_node", { clusterId, nodeName: selectedNode.name }); - setSelectedNode(null); + await uncordonNodeCmd(clusterId, node.name); + onRefresh?.(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to uncordon node"); - } finally { - setIsUncordoning(false); + setActionError(err instanceof Error ? err.message : String(err)); } }; const handleDrain = async () => { - if (!selectedNode) return; - - setIsDraining(true); - setError(null); + if (activeModal?.type !== "drain") return; + setIsActing(true); try { - await invoke("drain_node", { clusterId, nodeName: selectedNode.name }); - setSelectedNode(null); + await drainNodeCmd(clusterId, activeModal.node.name); + setActiveModal(null); + onRefresh?.(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to drain node"); + setActionError(err instanceof Error ? err.message : String(err)); } finally { - setIsDraining(false); + setIsActing(false); + } + }; + + const openEdit = async (node: NodeInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(clusterId, "nodes", "", node.name); + setActiveModal({ type: "edit", node, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); } }; return ( <> + {actionError && ( +

{actionError}

+ )}
@@ -116,14 +131,33 @@ export function NodeList({ nodes, clusterId }: NodeListProps) { {node.os_image} {node.age} - + handleCordon(node), + }, + { + label: "Uncordon", + icon: ShieldCheck, + hidden: !isSchedulingDisabled(node), + onClick: () => handleUncordon(node), + }, + { + label: "Drain", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "drain", node }), + }, + { + label: "Edit", + icon: Pencil, + onClick: () => openEdit(node), + }, + ]} + /> )) @@ -132,101 +166,28 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
- {/* Node Management Dialog */} - {selectedNode && ( - { - if (!open) { - setSelectedNode(null); - setError(null); - } - }}> - - - - - Manage Node: {selectedNode.name} - - + {activeModal?.type === "drain" && ( + { if (!o) setActiveModal(null); }} + resourceType="Node" + resourceName={activeModal.node.name} + isLoading={isActing} + onConfirm={handleDrain} + variant="force-delete" + /> + )} -
- {/* 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} - - )} -
-
-
+ {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> )} ); diff --git a/src/components/Kubernetes/PVCList.tsx b/src/components/Kubernetes/PVCList.tsx index 0d461754..7f8f03ac 100644 --- a/src/components/Kubernetes/PVCList.tsx +++ b/src/components/Kubernetes/PVCList.tsx @@ -1,50 +1,144 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { PersistentVolumeClaimInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface PVCListProps { pvcs: PersistentVolumeClaimInfo[]; - _clusterId: string; - _namespace: string; + clusterId?: string; + _clusterId?: string; + namespace?: string; + _namespace?: string; + onRefresh?: () => void; } -export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) { +type ActiveModal = + | { type: "edit"; pvc: PersistentVolumeClaimInfo; yaml: string } + | { type: "delete"; pvc: PersistentVolumeClaimInfo } + | null; + +export function PVCList({ + pvcs, + clusterId, + _clusterId, + namespace, + _namespace, + onRefresh, +}: PVCListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const ns = namespace ?? _namespace ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (pvc: PersistentVolumeClaimInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(cid, "persistentvolumeclaims", ns, pvc.name); + setActiveModal({ type: "edit", pvc, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsDeleting(true); + try { + await deleteResourceCmd(cid, "persistentvolumeclaims", ns, activeModal.pvc.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } + }; + return ( -
- - - - Name - Namespace - Status - Volume - Capacity - Access Modes - Age - - - - {pvcs.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No PVCs found - + Name + Namespace + Status + Volume + Capacity + Access Modes + Age + Actions - ) : ( - pvcs.map((pvc) => ( - - {pvc.name} - {pvc.namespace} - {pvc.status} - {pvc.volume} - {pvc.capacity} - {pvc.access_modes.join(", ")} - {pvc.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} + + openEdit(pvc), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", pvc }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="PVC" + resourceName={activeModal.pvc.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/PVList.tsx b/src/components/Kubernetes/PVList.tsx index bb42678e..7c85b813 100644 --- a/src/components/Kubernetes/PVList.tsx +++ b/src/components/Kubernetes/PVList.tsx @@ -1,49 +1,134 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { PersistentVolumeInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface PVListProps { pvs: PersistentVolumeInfo[]; - _clusterId: string; + clusterId?: string; + _clusterId?: string; + onRefresh?: () => void; } -export function PVList({ pvs, _clusterId }: PVListProps) { +type ActiveModal = + | { type: "edit"; pv: PersistentVolumeInfo; yaml: string } + | { type: "delete"; pv: PersistentVolumeInfo } + | null; + +export function PVList({ pvs, clusterId, _clusterId, onRefresh }: PVListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const openEdit = async (pv: PersistentVolumeInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(cid, "persistentvolumes", "", pv.name); + setActiveModal({ type: "edit", pv, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsDeleting(true); + try { + await deleteResourceCmd(cid, "persistentvolumes", "", activeModal.pv.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } + }; + return ( -
- - - - Name - Status - Capacity - Access Modes - Reclaim Policy - Storage Class - Age - - - - {pvs.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No PVs found - + Name + Status + Capacity + Access Modes + Reclaim Policy + Storage Class + Age + Actions - ) : ( - pvs.map((pv) => ( - - {pv.name} - {pv.status} - {pv.capacity} - {pv.access_modes.join(", ")} - {pv.reclaim_policy} - {pv.storage_class} - {pv.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} + + openEdit(pv), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", pv }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="PersistentVolume" + resourceName={activeModal.pv.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/PodDisruptionBudgetList.tsx b/src/components/Kubernetes/PodDisruptionBudgetList.tsx new file mode 100644 index 00000000..8287054c --- /dev/null +++ b/src/components/Kubernetes/PodDisruptionBudgetList.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { PodDisruptionBudgetInfo } from "@/lib/tauriCommands"; + +interface PodDisruptionBudgetListProps { + items: PodDisruptionBudgetInfo[]; + clusterId: string; + namespace?: string; +} + +export function PodDisruptionBudgetList({ items }: PodDisruptionBudgetListProps) { + return ( +
+ + + + Name + Namespace + Min Available + Max Unavailable + Disruptions Allowed + Age + + + + {items.length === 0 ? ( + + + No pod disruption budgets found + + + ) : ( + items.map((pdb) => ( + + {pdb.name} + {pdb.namespace} + {pdb.min_available} + {pdb.max_unavailable} + {pdb.disruptions_allowed} + {pdb.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/PodList.tsx b/src/components/Kubernetes/PodList.tsx index e214918a..29d5dd5a 100644 --- a/src/components/Kubernetes/PodList.tsx +++ b/src/components/Kubernetes/PodList.tsx @@ -1,28 +1,36 @@ 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 { 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"; +import { FileText, Terminal, Link, Pencil, Trash2, Zap } from "lucide-react"; +import type { PodInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, forceDeleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { LogsModal } from "./LogsModal"; +import { ShellExecModal } from "./ShellExecModal"; +import { AttachModal } from "./AttachModal"; +import { EditResourceModal } from "./EditResourceModal"; interface PodListProps { pods: PodInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function PodList({ pods, clusterId, namespace }: PodListProps) { - const [selectedPod, setSelectedPod] = useState(null); - const [selectedContainer, setSelectedContainer] = useState(""); - const [logs, setLogs] = useState(""); - const [isFetchingLogs, setIsFetchingLogs] = useState(false); - const [error, setError] = useState(null); - const [isDialogOpen, setIsDialogOpen] = useState(false); +type ActiveModal = + | { type: "logs"; pod: PodInfo } + | { type: "shell"; pod: PodInfo } + | { type: "attach"; pod: PodInfo } + | { type: "edit"; pod: PodInfo; yaml: string } + | { type: "delete"; pod: PodInfo } + | { type: "force-delete"; pod: PodInfo } + | null; + +export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [editError, setEditError] = useState(null); const getPodStatusColor = (status: string) => { switch (status.toLowerCase()) { @@ -41,37 +49,41 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) { } }; - const fetchLogs = async () => { - if (!selectedPod || !selectedContainer) return; - - setIsFetchingLogs(true); - setError(null); + const openEdit = async (pod: PodInfo) => { + setEditError(null); try { - const response = await invoke("get_pod_logs", { - clusterId, - namespace, - podName: selectedPod.name, - containerName: selectedContainer, - }); - setLogs(response.logs); + const yaml = await getResourceYamlCmd(clusterId, "pods", namespace, pod.name); + setActiveModal({ type: "edit", pod, yaml }); } catch (err) { - console.error("Failed to fetch logs:", err); - setError(err instanceof Error ? err.message : "Failed to fetch logs"); - } finally { - setIsFetchingLogs(false); + setEditError(err instanceof Error ? err.message : String(err)); } }; - const handleContainerChange = (container: string) => { - setSelectedContainer(container); - setLogs(""); - setError(null); + const handleDelete = async (force: boolean) => { + const modal = activeModal; + if (!modal || (modal.type !== "delete" && modal.type !== "force-delete")) return; + setIsDeleting(true); + try { + if (force) { + await forceDeleteResourceCmd(clusterId, "pods", namespace, modal.pod.name); + } else { + await deleteResourceCmd(clusterId, "pods", namespace, modal.pod.name); + } + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } }; - const containers = selectedPod?.containers ?? []; + const currentPod = + activeModal && activeModal.type !== "edit" ? activeModal.pod : null; return ( <> + {editError && ( +

{editError}

+ )}
@@ -102,91 +114,46 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) { {pod.ready} {pod.age} - - - - - {pod.name} - {namespace} namespace - -
- {selectedPod && ( -
-
- Container: - - -
- - {error && ( - - {error} - - )} - - {}}> - - Logs - Details - -
- -