feat(kube): nav restructure, action menus, new resource lists, advanced components
Navigation: - Restructure to match requested layout: Cluster, Nodes, Workloads, Config, Network, Storage, Namespaces, Events, Helm, Access Control, Custom Resources - Workloads: add Overview dashboard and Replication Controllers - Config: add PDB, PriorityClass, RuntimeClass, Lease, Mutating/Validating Webhooks - Network: add Endpoints, EndpointSlices, IngressClasses; move Port Forwarding here - Helm and Custom Resources sections wired through New shared components: - ResourceActionMenu: state-aware MoreHorizontal dropdown - ConfirmDeleteDialog: confirmation guard for all destructive operations - ScaleModal: replica count dialog (Deployments, StatefulSets, ReplicaSets, RCs) - LogsModal: container log viewer replacing PodList inline dialog - ShellExecModal: kubectl exec -it with container and shell selector - AttachModal: kubectl attach -it with container selector New resource list components (12): ReplicationControllerList, PodDisruptionBudgetList, PriorityClassList, RuntimeClassList, LeaseList, MutatingWebhookList, ValidatingWebhookList, EndpointList, EndpointSliceList, IngressClassList, NamespaceList, WorkloadOverview New advanced components (5): LogStreamPanel (Tauri-event streaming, follow/search/download), HelmChartList, HelmReleaseList, CrdList, CustomResourceList Updated 24 existing list components with context-appropriate action menus: - Pods: Logs, Shell, Attach, Edit, Delete, Force Delete (state-aware) - Deployments: Scale, Restart, Rollback, Edit, Delete - StatefulSets/ReplicaSets: Scale, Restart/none, Edit, Delete - DaemonSets: Restart, Edit, Delete - Jobs: Edit, Delete - CronJobs: Suspend/Resume (state-aware), Trigger, Edit, Delete - Services/Ingresses/ConfigMaps/Secrets/HPAs/PVCs/PVs/StorageClasses/ NetworkPolicies/ResourceQuotas/LimitRanges: Edit, Delete - Nodes: Cordon/Uncordon (state-aware), Drain, Edit - All RBAC resources: Edit, Delete Co-Authored-By: TFTSR Engineering <noreply@tftsr.com>
This commit is contained in:
parent
879fdf4239
commit
aee739c078
112
src/components/Kubernetes/AttachModal.tsx
Normal file
112
src/components/Kubernetes/AttachModal.tsx
Normal file
@ -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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Attach — <span className="font-mono">{podName}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedContainer} onValueChange={setSelectedContainer}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select container" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAttach}
|
||||
disabled={!selectedContainer || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Attaching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link className="mr-2 h-4 w-4" />
|
||||
Attach
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<pre className="max-h-[50vh] overflow-auto rounded-md bg-black p-3 font-mono text-xs text-green-400 whitespace-pre-wrap break-all">
|
||||
{output || "Select a container and click Attach."}
|
||||
</pre>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,62 @@
|
||||
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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -16,12 +64,13 @@ export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: Clus
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Cluster Role</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clusterRoleBindings.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No cluster role bindings found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -31,11 +80,52 @@ export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: Clus
|
||||
<TableCell className="font-medium">{crb.name}</TableCell>
|
||||
<TableCell>{crb.cluster_role}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{crb.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(crb),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", crb }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace=""
|
||||
resourceType="clusterrolebindings"
|
||||
resourceName={activeModal.crb.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="ClusterRoleBinding"
|
||||
resourceName={activeModal.crb.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clusterRoles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
No cluster roles found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
clusterRoles.map((clusterRole) => (
|
||||
<TableRow key={clusterRole.name}>
|
||||
<TableCell className="font-medium">{clusterRole.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{clusterRole.age}</TableCell>
|
||||
clusterRoles.map((cr) => (
|
||||
<TableRow key={cr.name}>
|
||||
<TableCell className="font-medium">{cr.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{cr.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(cr),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", cr }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace=""
|
||||
resourceType="clusterroles"
|
||||
resourceName={activeModal.cr.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="ClusterRole"
|
||||
resourceName={activeModal.cr.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,17 +1,56 @@
|
||||
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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -31,21 +70,28 @@ export function ConfigMapList({ configmaps }: ConfigMapListProps) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
configmaps.map((configmap) => (
|
||||
<TableRow key={configmap.name}>
|
||||
<TableCell className="font-medium">{configmap.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{configmap.namespace}</TableCell>
|
||||
<TableCell className="text-sm">{configmap.data_keys}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{configmap.age}</TableCell>
|
||||
configmaps.map((cm) => (
|
||||
<TableRow key={cm.name}>
|
||||
<TableCell className="font-medium">{cm.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{cm.namespace}</TableCell>
|
||||
<TableCell className="text-sm">{cm.data_keys}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{cm.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {}}
|
||||
className="text-primary hover:text-primary hover:bg-primary/10"
|
||||
>
|
||||
View/Edit
|
||||
</Button>
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(cm),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", cm }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@ -53,5 +99,29 @@ export function ConfigMapList({ configmaps }: ConfigMapListProps) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
resourceType="configmaps"
|
||||
resourceName={activeModal.cm.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="ConfigMap"
|
||||
resourceName={activeModal.cm.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
85
src/components/Kubernetes/ConfirmDeleteDialog.tsx
Normal file
85
src/components/Kubernetes/ConfirmDeleteDialog.tsx
Normal file
@ -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> | 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
{isForce ? `Force Delete ${resourceType}` : `Delete ${resourceType}`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isForce ? (
|
||||
<>
|
||||
Are you sure you want to <strong>force delete</strong>{" "}
|
||||
<span className="font-mono text-foreground">{resourceName}</span>?
|
||||
<br />
|
||||
<span className="mt-1 block text-destructive">
|
||||
This will immediately terminate the resource with no grace period.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-mono text-foreground">{resourceName}</span>? This
|
||||
action cannot be undone.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirm} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : isForce ? (
|
||||
"Force Delete"
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
142
src/components/Kubernetes/CrdList.tsx
Normal file
142
src/components/Kubernetes/CrdList.tsx
Normal file
@ -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<CrdInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedCrd, setExpandedCrd] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground">
|
||||
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
|
||||
Loading CRDs…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{crds.length} custom resource definition{crds.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => void loadCrds()}>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
{crds.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
|
||||
No custom resource definitions found
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground">
|
||||
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Kind</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Group</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Version</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Scope</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{crds.map((crd) => {
|
||||
const isExpanded = expandedCrd === crd.name;
|
||||
return (
|
||||
<React.Fragment key={crd.name}>
|
||||
<tr
|
||||
className="border-b last:border-0 hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
onClick={() => handleRowClick(crd)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 font-mono text-xs">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{crd.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">{crd.kind}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">{crd.group}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{crd.version}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={scopeVariant(crd.scope)}>
|
||||
{crd.scope}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{crd.age}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="border-b bg-muted/10">
|
||||
<td colSpan={6} className="px-6 py-3">
|
||||
<CustomResourceList
|
||||
clusterId={clusterId}
|
||||
namespace={crd.scope === "Namespaced" ? "" : ""}
|
||||
group={crd.group}
|
||||
version={crd.version}
|
||||
resource={crd.name.split(".")[0] ?? crd.name}
|
||||
kind={crd.kind}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,108 @@
|
||||
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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -21,34 +114,93 @@ export function CronJobList({ cronJobs, _clusterId, _namespace }: CronJobListPro
|
||||
<TableHead>Last Schedule</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead>Labels</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cronJobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No cron jobs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
cronJobs.map((cronJob) => (
|
||||
<TableRow key={`${cronJob.name}-${cronJob.namespace}`}>
|
||||
<TableCell className="font-medium">{cronJob.name}</TableCell>
|
||||
<TableCell>{cronJob.namespace}</TableCell>
|
||||
<TableCell>{cronJob.schedule}</TableCell>
|
||||
<TableCell>{cronJob.active}</TableCell>
|
||||
<TableCell>{cronJob.last_schedule}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{cronJob.age}</TableCell>
|
||||
cronJobs.map((cj) => (
|
||||
<TableRow key={`${cj.name}-${cj.namespace}`}>
|
||||
<TableCell className="font-medium">{cj.name}</TableCell>
|
||||
<TableCell>{cj.namespace}</TableCell>
|
||||
<TableCell>{cj.schedule}</TableCell>
|
||||
<TableCell>{cj.active}</TableCell>
|
||||
<TableCell>{cj.last_schedule}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{cj.age}</TableCell>
|
||||
<TableCell>
|
||||
{Object.entries(cronJob.labels)
|
||||
{Object.entries(cj.labels)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Suspend",
|
||||
icon: PauseCircle,
|
||||
hidden: isSuspended(cj),
|
||||
onClick: () => 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 }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace={ns}
|
||||
resourceType="cronjobs"
|
||||
resourceName={activeModal.cj.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="CronJob"
|
||||
resourceName={activeModal.cj.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
100
src/components/Kubernetes/CustomResourceList.tsx
Normal file
100
src/components/Kubernetes/CustomResourceList.tsx
Normal file
@ -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<CustomResourceInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm py-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Loading {kind} instances…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No {kind} instances found.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const showNamespace = items.some((item) => item.namespace !== "");
|
||||
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground bg-muted/30">
|
||||
<th className="text-left px-4 py-2 font-medium">Name</th>
|
||||
{showNamespace && (
|
||||
<th className="text-left px-4 py-2 font-medium">Namespace</th>
|
||||
)}
|
||||
<th className="text-left px-4 py-2 font-medium">Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={`${item.namespace}/${item.name}`}
|
||||
className="border-b last:border-0 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2 font-mono text-xs font-medium">{item.name}</td>
|
||||
{showNamespace && (
|
||||
<td className="px-4 py-2 text-muted-foreground">{item.namespace || "—"}</td>
|
||||
)}
|
||||
<td className="px-4 py-2 text-muted-foreground">{item.age}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,75 @@
|
||||
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<ActiveModal>(null);
|
||||
const [isActing, setIsActing] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -21,12 +81,13 @@ export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _n
|
||||
<TableHead>Up-to-date</TableHead>
|
||||
<TableHead>Available</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{daemonsets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No daemonsets found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -40,11 +101,69 @@ export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _n
|
||||
<TableCell>{ds.up_to_date}</TableCell>
|
||||
<TableCell>{ds.available}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{ds.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Restart",
|
||||
icon: RotateCcw,
|
||||
onClick: () => setActiveModal({ type: "restart", ds }),
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(ds),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", ds }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "restart" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="DaemonSet"
|
||||
resourceName={activeModal.ds.name}
|
||||
isLoading={isActing}
|
||||
onConfirm={handleRestart}
|
||||
variant="delete"
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
resourceType="daemonsets"
|
||||
resourceName={activeModal.ds.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="DaemonSet"
|
||||
resourceName={activeModal.ds.name}
|
||||
isLoading={isActing}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<DeploymentInfo | null>(null);
|
||||
const [replicas, setReplicas] = useState<string>("");
|
||||
const [isScaling, setIsScaling] = useState(false);
|
||||
const [scaleError, setScaleError] = useState<string | null>(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<DeploymentInfo | null>(null);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [restartError, setRestartError] = useState<string | null>(null);
|
||||
|
||||
const handleScaleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setReplicas(e.target.value);
|
||||
setScaleError(null);
|
||||
};
|
||||
|
||||
const handleScaleSubmit = async () => {
|
||||
if (!scalingDeployment) return;
|
||||
|
||||
const newReplicas = parseInt(replicas, 10);
|
||||
if (isNaN(newReplicas) || newReplicas < 0) {
|
||||
setScaleError("Invalid replica count");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsScaling(true);
|
||||
setScaleError(null);
|
||||
export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: DeploymentListProps) {
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [isActing, setIsActing] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openEdit = async (deployment: DeploymentInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await invoke<void>("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<void>("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 && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -114,24 +119,36 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment
|
||||
<TableCell>{deployment.replicas}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{deployment.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScalingDeployment(deployment)}
|
||||
>
|
||||
<Scale className="w-4 h-4" />
|
||||
Scale
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRestartingDeployment(deployment)}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Scale",
|
||||
icon: Scale,
|
||||
onClick: () => 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 }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@ -140,69 +157,68 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Scale Dialog */}
|
||||
<Dialog open={!!scalingDeployment} onOpenChange={() => setScalingDeployment(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Scale Deployment</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="replicas">Replica Count</Label>
|
||||
<Input
|
||||
id="replicas"
|
||||
type="number"
|
||||
value={replicas}
|
||||
onChange={handleScaleChange}
|
||||
placeholder="Enter replica count"
|
||||
min="0"
|
||||
{activeModal?.type === "scale" && (
|
||||
<ScaleModal
|
||||
open
|
||||
onOpenChange={(o) => { 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?.();
|
||||
})
|
||||
}
|
||||
/>
|
||||
{scaleError && (
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{scaleError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setScalingDeployment(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleScaleSubmit} disabled={isScaling}>
|
||||
{isScaling ? "Scaling..." : "Scale"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Restart Dialog */}
|
||||
<Dialog open={!!restartingDeployment} onOpenChange={() => setRestartingDeployment(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restart Deployment</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will trigger a rolling restart of the deployment.
|
||||
</p>
|
||||
{restartError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{restartError}</AlertDescription>
|
||||
</Alert>
|
||||
{activeModal?.type === "restart" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Deployment"
|
||||
resourceName={activeModal.deployment.name}
|
||||
isLoading={isActing}
|
||||
onConfirm={handleRestart}
|
||||
variant="delete"
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "rollback" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Deployment"
|
||||
resourceName={activeModal.deployment.name}
|
||||
isLoading={isActing}
|
||||
onConfirm={handleRollback}
|
||||
variant="delete"
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
resourceType="deployments"
|
||||
resourceName={activeModal.deployment.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Deployment"
|
||||
resourceName={activeModal.deployment.name}
|
||||
isLoading={isActing}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRestartingDeployment(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRestartSubmit} disabled={isRestarting}>
|
||||
{isRestarting ? "Restarting..." : "Restart"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
50
src/components/Kubernetes/EndpointList.tsx
Normal file
50
src/components/Kubernetes/EndpointList.tsx
Normal file
@ -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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Addresses</TableHead>
|
||||
<TableHead>Ports</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No endpoints found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((ep) => (
|
||||
<TableRow key={`${ep.name}-${ep.namespace}`}>
|
||||
<TableCell className="font-medium">{ep.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{ep.namespace}</TableCell>
|
||||
<TableCell className="text-sm font-mono">
|
||||
{ep.addresses.length > 0 ? ep.addresses.join(", ") : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{ep.ports.length > 0 ? ep.ports.join(", ") : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{ep.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/Kubernetes/EndpointSliceList.tsx
Normal file
50
src/components/Kubernetes/EndpointSliceList.tsx
Normal file
@ -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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Address Type</TableHead>
|
||||
<TableHead>Endpoints</TableHead>
|
||||
<TableHead>Ports</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No endpoint slices found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((eps) => (
|
||||
<TableRow key={`${eps.name}-${eps.namespace}`}>
|
||||
<TableCell className="font-medium">{eps.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{eps.namespace}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{eps.address_type}</TableCell>
|
||||
<TableCell className="text-sm">{eps.endpoints}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{eps.ports.length > 0 ? eps.ports.join(", ") : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{eps.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,67 @@
|
||||
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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -21,12 +73,13 @@ export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) {
|
||||
<TableHead>Current Replicas</TableHead>
|
||||
<TableHead>Desired Replicas</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hpas.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No HPAs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -40,11 +93,52 @@ export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) {
|
||||
<TableCell>{hpa.current_replicas}</TableCell>
|
||||
<TableCell>{hpa.desired_replicas}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{hpa.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(hpa),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", hpa }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace={ns}
|
||||
resourceType="horizontalpodautoscalers"
|
||||
resourceName={activeModal.hpa.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="HPA"
|
||||
resourceName={activeModal.hpa.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
296
src/components/Kubernetes/HelmChartList.tsx
Normal file
296
src/components/Kubernetes/HelmChartList.tsx
Normal file
@ -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<HelmRepository[]>([]);
|
||||
const [charts, setCharts] = useState<HelmChart[]>([]);
|
||||
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updatingRepos, setUpdatingRepos] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedChart, setExpandedChart] = useState<string | null>(null);
|
||||
|
||||
const [addRepoOpen, setAddRepoOpen] = useState(false);
|
||||
const [newRepoName, setNewRepoName] = useState("");
|
||||
const [newRepoUrl, setNewRepoUrl] = useState("");
|
||||
const [addingRepo, setAddingRepo] = useState(false);
|
||||
const [addRepoError, setAddRepoError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-4 h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleUpdateRepos()}
|
||||
disabled={updatingRepos}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${updatingRepos ? "animate-spin" : ""}`} />
|
||||
Update Repos
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setAddRepoOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Repository
|
||||
</Button>
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search charts…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
{/* Repository sidebar */}
|
||||
<div className="w-48 flex-shrink-0 border rounded-md overflow-y-auto">
|
||||
<div className="px-3 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Repositories
|
||||
</div>
|
||||
<div
|
||||
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
|
||||
selectedRepo == null ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => setSelectedRepo(null)}
|
||||
>
|
||||
All repositories
|
||||
</div>
|
||||
{repos.map((repo) => (
|
||||
<div
|
||||
key={repo.name}
|
||||
className={`px-3 py-2 text-sm cursor-pointer transition-colors truncate ${
|
||||
selectedRepo === repo.name
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
title={repo.name}
|
||||
onClick={() => setSelectedRepo(repo.name)}
|
||||
>
|
||||
{repo.name}
|
||||
</div>
|
||||
))}
|
||||
{repos.length === 0 && !loading && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground">No repos</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Charts table */}
|
||||
<div className="flex-1 overflow-auto border rounded-md">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground">
|
||||
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
|
||||
Loading charts…
|
||||
</div>
|
||||
) : repos.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-center gap-2 text-muted-foreground text-sm px-4">
|
||||
<p>No helm repositories configured.</p>
|
||||
<p>Add a repository to get started.</p>
|
||||
</div>
|
||||
) : filteredCharts.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
|
||||
No charts match your search.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground">
|
||||
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Version</th>
|
||||
<th className="text-left px-4 py-3 font-medium">App Version</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Repository</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCharts.map((chart) => {
|
||||
const key = `${chart.repository}/${chart.name}`;
|
||||
const isExpanded = expandedChart === key;
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<tr
|
||||
className="border-b last:border-0 hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
onClick={() => setExpandedChart(isExpanded ? null : key)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{chart.name.includes("/") ? chart.name.split("/").slice(1).join("/") : chart.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{chart.chart_version}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{chart.app_version || "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{chart.repository}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground max-w-xs truncate">
|
||||
{chart.description || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="border-b bg-muted/20">
|
||||
<td colSpan={5} className="px-6 py-3">
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="font-medium">
|
||||
{chart.repository}/{chart.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground">{chart.description || "No description available."}</div>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span>Chart: {chart.chart_version}</span>
|
||||
{chart.app_version && <span>App: {chart.app_version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Repository Dialog */}
|
||||
<Dialog open={addRepoOpen} onOpenChange={setAddRepoOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Helm Repository</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="repo-name">Name</Label>
|
||||
<Input
|
||||
id="repo-name"
|
||||
placeholder="e.g. stable"
|
||||
value={newRepoName}
|
||||
onChange={(e) => setNewRepoName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="repo-url">URL</Label>
|
||||
<Input
|
||||
id="repo-url"
|
||||
placeholder="https://charts.helm.sh/stable"
|
||||
value={newRepoUrl}
|
||||
onChange={(e) => setNewRepoUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{addRepoError && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{addRepoError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddRepoOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAddRepo()}
|
||||
disabled={addingRepo || !newRepoName.trim() || !newRepoUrl.trim()}
|
||||
>
|
||||
{addingRepo ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
src/components/Kubernetes/HelmReleaseList.tsx
Normal file
262
src/components/Kubernetes/HelmReleaseList.tsx
Normal file
@ -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<HelmRelease[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [actionInProgress, setActionInProgress] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground">
|
||||
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
|
||||
Loading releases…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{releases.length} release{releases.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => void loadReleases()}>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Chart</TableHead>
|
||||
<TableHead>Chart Version</TableHead>
|
||||
<TableHead>App Version</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Updated</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{releases.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No releases found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
releases.map((release) => {
|
||||
const menuKey = `${release.namespace}/${release.name}`;
|
||||
return (
|
||||
<TableRow key={menuKey}>
|
||||
<TableCell className="font-medium">{release.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{release.namespace}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{release.chart}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{release.chart_version}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{release.app_version || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(release.status)}>
|
||||
{statusLabel(release.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{release.updated}</TableCell>
|
||||
<TableCell>
|
||||
<div className="relative">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setOpenMenuId(openMenuId === menuKey ? null : menuKey)
|
||||
}
|
||||
aria-label="Actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
{openMenuId === menuKey && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 z-50 w-36 rounded-md border bg-card shadow-md"
|
||||
onMouseLeave={() => setOpenMenuId(null)}
|
||||
>
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null);
|
||||
setConfirmAction({ type: "rollback", release });
|
||||
}}
|
||||
>
|
||||
Rollback
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null);
|
||||
setConfirmAction({ type: "uninstall", release });
|
||||
}}
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
<Dialog open={confirmAction != null} onOpenChange={(o) => { if (!o) setConfirmAction(null); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{confirmAction?.type === "rollback" ? "Rollback Release" : "Uninstall Release"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{confirmAction?.type === "rollback" ? (
|
||||
<>
|
||||
Roll back <span className="font-medium text-foreground">{confirmAction.release.name}</span> to the
|
||||
previous revision? This cannot be undone without a re-deploy.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Permanently uninstall <span className="font-medium text-foreground">{confirmAction?.release.name}</span>?
|
||||
All Kubernetes resources created by this release will be removed.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{actionError && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmAction(null)} disabled={actionInProgress}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmAction?.type === "uninstall" ? "destructive" : "default"}
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={actionInProgress}
|
||||
>
|
||||
{actionInProgress
|
||||
? "Working…"
|
||||
: confirmAction?.type === "rollback"
|
||||
? "Rollback"
|
||||
: "Uninstall"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/Kubernetes/IngressClassList.tsx
Normal file
50
src/components/Kubernetes/IngressClassList.tsx
Normal file
@ -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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Controller</TableHead>
|
||||
<TableHead>Default</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No ingress classes found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((ic) => (
|
||||
<TableRow key={ic.name}>
|
||||
<TableCell className="font-medium">{ic.name}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{ic.controller}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{ic.is_default ? (
|
||||
<Badge variant="success">Yes</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{ic.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,67 @@
|
||||
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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -20,12 +72,13 @@ export function IngressList({ ingresses, _clusterId, _namespace }: IngressListPr
|
||||
<TableHead>Host</TableHead>
|
||||
<TableHead>Addresses</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ingresses.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No ingresses found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -38,11 +91,52 @@ export function IngressList({ ingresses, _clusterId, _namespace }: IngressListPr
|
||||
<TableCell>{ingress.host}</TableCell>
|
||||
<TableCell>{ingress.addresses.join(", ")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{ingress.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(ingress),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", ingress }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace={ns}
|
||||
resourceType="ingresses"
|
||||
resourceName={activeModal.ingress.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Ingress"
|
||||
resourceName={activeModal.ingress.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,67 @@
|
||||
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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -20,12 +72,13 @@ export function JobList({ jobs, _clusterId, _namespace }: JobListProps) {
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead>Labels</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No jobs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -42,11 +95,52 @@ export function JobList({ jobs, _clusterId, _namespace }: JobListProps) {
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(job),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", job }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace={ns}
|
||||
resourceType="jobs"
|
||||
resourceName={activeModal.job.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Job"
|
||||
resourceName={activeModal.job.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
44
src/components/Kubernetes/LeaseList.tsx
Normal file
44
src/components/Kubernetes/LeaseList.tsx
Normal file
@ -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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Holder</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No leases found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((lease) => (
|
||||
<TableRow key={`${lease.name}-${lease.namespace}`}>
|
||||
<TableCell className="font-medium">{lease.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{lease.namespace}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{lease.holder || "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{lease.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,56 @@
|
||||
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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -18,12 +59,13 @@ export function LimitRangeList({ limitranges }: LimitRangeListProps) {
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Limits</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{limitranges.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No limit ranges found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -34,11 +76,52 @@ export function LimitRangeList({ limitranges }: LimitRangeListProps) {
|
||||
<TableCell className="text-sm text-muted-foreground">{lr.namespace}</TableCell>
|
||||
<TableCell className="text-sm">{lr.limit_count}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{lr.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(lr),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", lr }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
resourceType="limitranges"
|
||||
resourceName={activeModal.lr.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="LimitRange"
|
||||
resourceName={activeModal.lr.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
294
src/components/Kubernetes/LogStreamPanel.tsx
Normal file
294
src/components/Kubernetes/LogStreamPanel.tsx
Normal file
@ -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<string>(
|
||||
containers[0] ?? ""
|
||||
);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const [timestamps, setTimestamps] = useState(false);
|
||||
const [tailLines, setTailLines] = useState(100);
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const streamIdRef = useRef<string | null>(null);
|
||||
const unlistenRef = useRef<UnlistenFn | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl w-full max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Log Stream — {podName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 overflow-hidden" style={{ maxHeight: "calc(80vh - 80px)" }}>
|
||||
{/* Controls row */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={selectedContainer}
|
||||
onChange={(e) => setSelectedContainer(e.target.value)}
|
||||
disabled={streaming}
|
||||
className="flex h-9 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"
|
||||
>
|
||||
{containers.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input"
|
||||
checked={follow}
|
||||
disabled={streaming}
|
||||
onChange={(e) => setFollow(e.target.checked)}
|
||||
/>
|
||||
Follow
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input"
|
||||
checked={timestamps}
|
||||
disabled={streaming}
|
||||
onChange={(e) => setTimestamps(e.target.checked)}
|
||||
/>
|
||||
Timestamps
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<span className="text-muted-foreground whitespace-nowrap">Tail lines:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={tailLines}
|
||||
min={10}
|
||||
max={10000}
|
||||
disabled={streaming}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{!streaming ? (
|
||||
<Button size="sm" onClick={() => void startStream()}>
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
Stream
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="destructive" onClick={() => void stopStream()}>
|
||||
<Square className="h-3.5 w-3.5 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={handleDownload} disabled={lines.length === 0}>
|
||||
<Download className="h-3.5 w-3.5 mr-1" />
|
||||
Download
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleClear} disabled={lines.length === 0}>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter log lines…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log output */}
|
||||
<div className="flex-1 overflow-y-auto rounded-md border bg-slate-950 p-3 font-mono text-xs text-slate-200 min-h-0">
|
||||
{displayLines.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{streaming ? "Waiting for log data…" : "No logs to display. Press Stream to begin."}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{(search.trim() !== "" ? lines : displayLines).map((line, i) => {
|
||||
const matches = search.trim() !== "" && line.includes(search);
|
||||
const visible = search.trim() === "" || matches;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={[
|
||||
"whitespace-pre-wrap break-all leading-5",
|
||||
!visible ? "opacity-40" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{matches && search.trim() !== "" ? (
|
||||
highlightMatch(line, search)
|
||||
) : (
|
||||
line
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{lines.length.toLocaleString()} line{lines.length !== 1 ? "s" : ""}
|
||||
{search.trim() !== "" && ` — ${filteredLines.length.toLocaleString()} matching`}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function highlightMatch(line: string, search: string): React.ReactNode {
|
||||
const idx = line.indexOf(search);
|
||||
if (idx === -1) return line;
|
||||
return (
|
||||
<>
|
||||
{line.slice(0, idx)}
|
||||
<mark className="bg-amber-400/30 text-amber-200 rounded-sm px-0.5">{line.slice(idx, idx + search.length)}</mark>
|
||||
{line.slice(idx + search.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
src/components/Kubernetes/LogsModal.tsx
Normal file
110
src/components/Kubernetes/LogsModal.tsx
Normal file
@ -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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Logs — <span className="font-mono">{podName}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedContainer} onValueChange={setSelectedContainer}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select container" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={fetchLogs}
|
||||
disabled={!selectedContainer || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Fetch Logs
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<pre className="max-h-[50vh] overflow-auto rounded-md border bg-muted p-3 font-mono text-xs whitespace-pre-wrap break-all">
|
||||
{logs || "No logs. Select a container and click Fetch Logs."}
|
||||
</pre>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
42
src/components/Kubernetes/MutatingWebhookList.tsx
Normal file
42
src/components/Kubernetes/MutatingWebhookList.tsx
Normal file
@ -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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Webhooks</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
No mutating webhook configurations found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((wh) => (
|
||||
<TableRow key={wh.name}>
|
||||
<TableCell className="font-medium">{wh.name}</TableCell>
|
||||
<TableCell className="text-sm">{wh.webhooks}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{wh.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/Kubernetes/NamespaceList.tsx
Normal file
50
src/components/Kubernetes/NamespaceList.tsx
Normal file
@ -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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
No namespaces found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((ns) => (
|
||||
<TableRow key={ns.name}>
|
||||
<TableCell className="font-medium">{ns.name}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<Badge variant={statusVariant(ns.status)}>{ns.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{ns.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,56 @@
|
||||
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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -19,12 +60,13 @@ export function NetworkPolicyList({ networkpolicies }: NetworkPolicyListProps) {
|
||||
<TableHead>Pod Selector</TableHead>
|
||||
<TableHead>Policy Types</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{networkpolicies.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No network policies found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -36,11 +78,52 @@ export function NetworkPolicyList({ networkpolicies }: NetworkPolicyListProps) {
|
||||
<TableCell className="text-sm font-mono truncate max-w-48">{np.pod_selector}</TableCell>
|
||||
<TableCell className="text-sm">{np.policy_types.join(", ") || "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{np.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(np),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", np }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
resourceType="networkpolicies"
|
||||
resourceName={activeModal.np.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="NetworkPolicy"
|
||||
resourceName={activeModal.np.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<NodeInfo | null>(null);
|
||||
const [isCordoning, setIsCordoning] = useState(false);
|
||||
const [isUncordoning, setIsUncordoning] = useState(false);
|
||||
const [isDraining, setIsDraining] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<ActiveModal>(null);
|
||||
const [isActing, setIsActing] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const getNodeStatusColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
@ -33,53 +42,59 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCordon = async () => {
|
||||
if (!selectedNode) return;
|
||||
const isSchedulingDisabled = (node: NodeInfo) =>
|
||||
node.status.toLowerCase().includes("schedulingdisabled") ||
|
||||
node.roles.toLowerCase().includes("schedulingdisabled");
|
||||
|
||||
setIsCordoning(true);
|
||||
setError(null);
|
||||
const handleCordon = async (node: NodeInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await invoke<void>("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<void>("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<void>("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 && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -116,14 +131,33 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
|
||||
<TableCell className="text-sm text-muted-foreground">{node.os_image}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{node.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedNode(node)}
|
||||
className="text-primary hover:text-primary hover:bg-primary/10"
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Cordon",
|
||||
icon: ShieldOff,
|
||||
hidden: isSchedulingDisabled(node),
|
||||
onClick: () => 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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@ -132,101 +166,28 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Node Management Dialog */}
|
||||
{selectedNode && (
|
||||
<Dialog open={true} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedNode(null);
|
||||
setError(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5" />
|
||||
Manage Node: {selectedNode.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Node Details */}
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Status</p>
|
||||
<p className="font-semibold">{selectedNode.status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Roles</p>
|
||||
<p className="font-semibold">{selectedNode.roles}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Version</p>
|
||||
<p className="font-semibold">{selectedNode.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">OS Image</p>
|
||||
<p className="font-semibold">{selectedNode.os_image}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Kernel</p>
|
||||
<p className="font-semibold">{selectedNode.kernel_version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Kubelet</p>
|
||||
<p className="font-semibold">{selectedNode.kubelet_version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Internal IP</p>
|
||||
<p className="font-semibold font-mono">{selectedNode.internal_ip}</p>
|
||||
</div>
|
||||
{selectedNode.external_ip && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">External IP</p>
|
||||
<p className="font-semibold font-mono">{selectedNode.external_ip}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
{selectedNode.roles.toLowerCase().includes("schedulingdisabled") ? (
|
||||
<Button
|
||||
onClick={handleUncordon}
|
||||
disabled={isUncordoning}
|
||||
className="w-full"
|
||||
>
|
||||
{isUncordoning ? "Uncordoning..." : "Uncordon Node"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleCordon}
|
||||
variant="outline"
|
||||
disabled={isCordoning}
|
||||
className="w-full"
|
||||
>
|
||||
{isCordoning ? "Cordoning..." : "Cordon Node"}
|
||||
</Button>
|
||||
{activeModal?.type === "drain" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Node"
|
||||
resourceName={activeModal.node.name}
|
||||
isLoading={isActing}
|
||||
onConfirm={handleDrain}
|
||||
variant="force-delete"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleDrain}
|
||||
variant="destructive"
|
||||
disabled={isDraining}
|
||||
className="w-full"
|
||||
>
|
||||
{isDraining ? "Draining..." : "Drain Node"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace=""
|
||||
resourceType="nodes"
|
||||
resourceName={activeModal.node.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,15 +1,67 @@
|
||||
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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -21,12 +73,13 @@ export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) {
|
||||
<TableHead>Capacity</TableHead>
|
||||
<TableHead>Access Modes</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pvcs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No PVCs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -40,11 +93,52 @@ export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) {
|
||||
<TableCell>{pvc.capacity}</TableCell>
|
||||
<TableCell>{pvc.access_modes.join(", ")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{pvc.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(pvc),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", pvc }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace={ns}
|
||||
resourceType="persistentvolumeclaims"
|
||||
resourceName={activeModal.pvc.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="PVC"
|
||||
resourceName={activeModal.pvc.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,14 +1,57 @@
|
||||
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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -20,12 +63,13 @@ export function PVList({ pvs, _clusterId }: PVListProps) {
|
||||
<TableHead>Reclaim Policy</TableHead>
|
||||
<TableHead>Storage Class</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pvs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No PVs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -39,11 +83,52 @@ export function PVList({ pvs, _clusterId }: PVListProps) {
|
||||
<TableCell>{pv.reclaim_policy}</TableCell>
|
||||
<TableCell>{pv.storage_class}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{pv.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(pv),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", pv }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace=""
|
||||
resourceType="persistentvolumes"
|
||||
resourceName={activeModal.pv.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="PersistentVolume"
|
||||
resourceName={activeModal.pv.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
48
src/components/Kubernetes/PodDisruptionBudgetList.tsx
Normal file
48
src/components/Kubernetes/PodDisruptionBudgetList.tsx
Normal file
@ -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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Min Available</TableHead>
|
||||
<TableHead>Max Unavailable</TableHead>
|
||||
<TableHead>Disruptions Allowed</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No pod disruption budgets found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((pdb) => (
|
||||
<TableRow key={`${pdb.name}-${pdb.namespace}`}>
|
||||
<TableCell className="font-medium">{pdb.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{pdb.namespace}</TableCell>
|
||||
<TableCell className="text-sm">{pdb.min_available}</TableCell>
|
||||
<TableCell className="text-sm">{pdb.max_unavailable}</TableCell>
|
||||
<TableCell className="text-sm">{pdb.disruptions_allowed}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{pdb.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<PodInfo | null>(null);
|
||||
const [selectedContainer, setSelectedContainer] = useState<string>("");
|
||||
const [logs, setLogs] = useState<string>("");
|
||||
const [isFetchingLogs, setIsFetchingLogs] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
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<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(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<LogResponse>("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 && (
|
||||
<p className="mb-2 text-sm text-destructive">{editError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -102,91 +114,46 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) {
|
||||
<TableCell>{pod.ready}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{pod.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setSelectedPod(pod); setIsDialogOpen(true); }}>
|
||||
<Terminal className="w-4 h-4" />
|
||||
</Button>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{pod.name} - {namespace} namespace</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto flex flex-col">
|
||||
{selectedPod && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Container:</span>
|
||||
<select
|
||||
value={selectedContainer}
|
||||
onChange={(e) => handleContainerChange(e.target.value)}
|
||||
className="flex h-9 w-32 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value="">Select container...</option>
|
||||
{containers.map((container) => (
|
||||
<option key={container} value={container}>
|
||||
{container}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
onClick={fetchLogs}
|
||||
disabled={!selectedContainer || isFetchingLogs}
|
||||
size="sm"
|
||||
>
|
||||
{isFetchingLogs ? (
|
||||
<>
|
||||
<RotateCcw className="w-4 h-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-4 h-4" />
|
||||
Fetch Logs
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs value="logs" onValueChange={() => {}}>
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<TabsContent value="logs" className="h-full">
|
||||
<Textarea
|
||||
value={logs}
|
||||
readOnly
|
||||
className="font-mono text-xs h-64"
|
||||
placeholder="No logs available. Click 'Fetch Logs' to retrieve."
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Logs",
|
||||
icon: FileText,
|
||||
onClick: () => setActiveModal({ type: "logs", pod }),
|
||||
},
|
||||
{
|
||||
label: "Shell",
|
||||
icon: Terminal,
|
||||
onClick: () => setActiveModal({ type: "shell", pod }),
|
||||
},
|
||||
{
|
||||
label: "Attach",
|
||||
icon: Link,
|
||||
onClick: () => setActiveModal({ type: "attach", pod }),
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(pod),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", pod }),
|
||||
},
|
||||
{
|
||||
label: "Force Delete",
|
||||
icon: Zap,
|
||||
variant: "destructive",
|
||||
hidden: !(
|
||||
pod.status.toLowerCase() === "running" ||
|
||||
pod.status.toLowerCase() === "pending"
|
||||
),
|
||||
onClick: () => setActiveModal({ type: "force-delete", pod }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="details" className="h-full">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="text-muted-foreground">Name:</div>
|
||||
<div>{selectedPod.name}</div>
|
||||
<div className="text-muted-foreground">Status:</div>
|
||||
<div>{selectedPod.status}</div>
|
||||
<div className="text-muted-foreground">Ready:</div>
|
||||
<div>{selectedPod.ready}</div>
|
||||
<div className="text-muted-foreground">Age:</div>
|
||||
<div>{selectedPod.age}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@ -194,6 +161,74 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "logs" && (
|
||||
<LogsModal
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
podName={activeModal.pod.name}
|
||||
containers={activeModal.pod.containers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "shell" && (
|
||||
<ShellExecModal
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
podName={activeModal.pod.name}
|
||||
containers={activeModal.pod.containers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "attach" && (
|
||||
<AttachModal
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
podName={activeModal.pod.name}
|
||||
containers={activeModal.pod.containers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
resourceType="pods"
|
||||
resourceName={activeModal.pod.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && currentPod && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Pod"
|
||||
resourceName={currentPod.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={() => handleDelete(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "force-delete" && currentPod && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Pod"
|
||||
resourceName={currentPod.name}
|
||||
variant="force-delete"
|
||||
isLoading={isDeleting}
|
||||
onConfirm={() => handleDelete(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
50
src/components/Kubernetes/PriorityClassList.tsx
Normal file
50
src/components/Kubernetes/PriorityClassList.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge } from "@/components/ui";
|
||||
import type { PriorityClassInfo } from "@/lib/tauriCommands";
|
||||
|
||||
interface PriorityClassListProps {
|
||||
items: PriorityClassInfo[];
|
||||
clusterId: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export function PriorityClassList({ items }: PriorityClassListProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
<TableHead>Global Default</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No priority classes found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((pc) => (
|
||||
<TableRow key={pc.name}>
|
||||
<TableCell className="font-medium">{pc.name}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{pc.value}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{pc.global_default ? (
|
||||
<Badge variant="success">Yes</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{pc.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,73 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Scale, Pencil, Trash2 } from "lucide-react";
|
||||
import type { ReplicaSetInfo } from "@/lib/tauriCommands";
|
||||
import {
|
||||
scaleReplicasetCmd,
|
||||
deleteResourceCmd,
|
||||
getResourceYamlCmd,
|
||||
} from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { ScaleModal } from "./ScaleModal";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface ReplicaSetListProps {
|
||||
replicaSets: ReplicaSetInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: string;
|
||||
_clusterId?: string;
|
||||
namespace?: string;
|
||||
_namespace?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function ReplicaSetList({ replicaSets, _clusterId, _namespace }: ReplicaSetListProps) {
|
||||
type ActiveModal =
|
||||
| { type: "scale"; rs: ReplicaSetInfo }
|
||||
| { type: "edit"; rs: ReplicaSetInfo; yaml: string }
|
||||
| { type: "delete"; rs: ReplicaSetInfo }
|
||||
| null;
|
||||
|
||||
export function ReplicaSetList({
|
||||
replicaSets,
|
||||
clusterId,
|
||||
_clusterId,
|
||||
namespace,
|
||||
_namespace,
|
||||
onRefresh,
|
||||
}: ReplicaSetListProps) {
|
||||
const cid = clusterId ?? _clusterId ?? "";
|
||||
const ns = namespace ?? _namespace ?? "";
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [isActing, setIsActing] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openEdit = async (rs: ReplicaSetInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const yaml = await getResourceYamlCmd(cid, "replicasets", ns, rs.name);
|
||||
setActiveModal({ type: "edit", rs, yaml });
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (activeModal?.type !== "delete") return;
|
||||
setIsActing(true);
|
||||
try {
|
||||
await deleteResourceCmd(cid, "replicasets", ns, activeModal.rs.name);
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
} finally {
|
||||
setIsActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -20,33 +78,96 @@ export function ReplicaSetList({ replicaSets, _clusterId, _namespace }: ReplicaS
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead>Labels</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{replicaSets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No replica sets found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
replicaSets.map((replicaSet) => (
|
||||
<TableRow key={`${replicaSet.name}-${replicaSet.namespace}`}>
|
||||
<TableCell className="font-medium">{replicaSet.name}</TableCell>
|
||||
<TableCell>{replicaSet.namespace}</TableCell>
|
||||
<TableCell>{replicaSet.replicas}</TableCell>
|
||||
<TableCell>{replicaSet.ready}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{replicaSet.age}</TableCell>
|
||||
replicaSets.map((rs) => (
|
||||
<TableRow key={`${rs.name}-${rs.namespace}`}>
|
||||
<TableCell className="font-medium">{rs.name}</TableCell>
|
||||
<TableCell>{rs.namespace}</TableCell>
|
||||
<TableCell>{rs.replicas}</TableCell>
|
||||
<TableCell>{rs.ready}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{rs.age}</TableCell>
|
||||
<TableCell>
|
||||
{Object.entries(replicaSet.labels)
|
||||
{Object.entries(rs.labels)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Scale",
|
||||
icon: Scale,
|
||||
onClick: () => setActiveModal({ type: "scale", rs }),
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(rs),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", rs }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "scale" && (
|
||||
<ScaleModal
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="ReplicaSet"
|
||||
resourceName={activeModal.rs.name}
|
||||
currentReplicas={activeModal.rs.replicas}
|
||||
onScale={(replicas) =>
|
||||
scaleReplicasetCmd(cid, ns, activeModal.rs.name, replicas).then(() => {
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace={ns}
|
||||
resourceType="replicasets"
|
||||
resourceName={activeModal.rs.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="ReplicaSet"
|
||||
resourceName={activeModal.rs.name}
|
||||
isLoading={isActing}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
48
src/components/Kubernetes/ReplicationControllerList.tsx
Normal file
48
src/components/Kubernetes/ReplicationControllerList.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import type { ReplicationControllerInfo } from "@/lib/tauriCommands";
|
||||
|
||||
interface ReplicationControllerListProps {
|
||||
items: ReplicationControllerInfo[];
|
||||
clusterId: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export function ReplicationControllerList({ items }: ReplicationControllerListProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Desired</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Current</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No replication controllers found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((rc) => (
|
||||
<TableRow key={`${rc.name}-${rc.namespace}`}>
|
||||
<TableCell className="font-medium">{rc.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{rc.namespace}</TableCell>
|
||||
<TableCell className="text-sm">{rc.desired}</TableCell>
|
||||
<TableCell className="text-sm">{rc.ready}</TableCell>
|
||||
<TableCell className="text-sm">{rc.current}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{rc.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/components/Kubernetes/ResourceActionMenu.tsx
Normal file
88
src/components/Kubernetes/ResourceActionMenu.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
export interface ResourceAction {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "destructive";
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface ResourceActionMenuProps {
|
||||
actions: ResourceAction[];
|
||||
triggerLabel?: string;
|
||||
}
|
||||
|
||||
export function ResourceActionMenu({ actions, triggerLabel = "Actions" }: ResourceActionMenuProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const visible = actions.filter((a) => !a.hidden);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative inline-block text-left">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={triggerLabel}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-50 mt-1 w-48 rounded-md border bg-card shadow-lg">
|
||||
<div className="py-1">
|
||||
{visible.map((action, idx) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
disabled={action.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
action.onClick();
|
||||
}}
|
||||
className={[
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors",
|
||||
action.disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
||||
action.variant === "destructive"
|
||||
? "text-destructive hover:text-destructive"
|
||||
: "text-foreground",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,56 @@
|
||||
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 { ResourceQuotaInfo } from "@/lib/tauriCommands";
|
||||
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface ResourceQuotaListProps {
|
||||
resourcequotas: ResourceQuotaInfo[];
|
||||
clusterId: string;
|
||||
namespace: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function ResourceQuotaList({ resourcequotas }: ResourceQuotaListProps) {
|
||||
type ActiveModal =
|
||||
| { type: "edit"; rq: ResourceQuotaInfo; yaml: string }
|
||||
| { type: "delete"; rq: ResourceQuotaInfo }
|
||||
| null;
|
||||
|
||||
export function ResourceQuotaList({ resourcequotas, clusterId, namespace, onRefresh }: ResourceQuotaListProps) {
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openEdit = async (rq: ResourceQuotaInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const yaml = await getResourceYamlCmd(clusterId, "resourcequotas", namespace, rq.name);
|
||||
setActiveModal({ type: "edit", rq, 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, "resourcequotas", namespace, activeModal.rq.name);
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -21,12 +62,13 @@ export function ResourceQuotaList({ resourcequotas }: ResourceQuotaListProps) {
|
||||
<TableHead>CPU Limit</TableHead>
|
||||
<TableHead>Mem Limit</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{resourcequotas.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No resource quotas found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -40,11 +82,52 @@ export function ResourceQuotaList({ resourcequotas }: ResourceQuotaListProps) {
|
||||
<TableCell className="text-sm font-mono">{rq.limit_cpu || "—"}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{rq.limit_memory || "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{rq.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(rq),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", rq }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
resourceType="resourcequotas"
|
||||
resourceName={activeModal.rq.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="ResourceQuota"
|
||||
resourceName={activeModal.rq.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,67 @@
|
||||
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 { RoleBindingInfo } from "@/lib/tauriCommands";
|
||||
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface RoleBindingListProps {
|
||||
roleBindings: RoleBindingInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: string;
|
||||
_clusterId?: string;
|
||||
namespace?: string;
|
||||
_namespace?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function RoleBindingList({ roleBindings, _clusterId, _namespace }: RoleBindingListProps) {
|
||||
type ActiveModal =
|
||||
| { type: "edit"; rb: RoleBindingInfo; yaml: string }
|
||||
| { type: "delete"; rb: RoleBindingInfo }
|
||||
| null;
|
||||
|
||||
export function RoleBindingList({
|
||||
roleBindings,
|
||||
clusterId,
|
||||
_clusterId,
|
||||
namespace,
|
||||
_namespace,
|
||||
onRefresh,
|
||||
}: RoleBindingListProps) {
|
||||
const cid = clusterId ?? _clusterId ?? "";
|
||||
const ns = namespace ?? _namespace ?? "";
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openEdit = async (rb: RoleBindingInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const yaml = await getResourceYamlCmd(cid, "rolebindings", ns, rb.name);
|
||||
setActiveModal({ type: "edit", rb, 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, "rolebindings", ns, activeModal.rb.name);
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -18,12 +70,13 @@ export function RoleBindingList({ roleBindings, _clusterId, _namespace }: RoleBi
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roleBindings.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No role bindings found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -34,11 +87,52 @@ export function RoleBindingList({ roleBindings, _clusterId, _namespace }: RoleBi
|
||||
<TableCell>{rb.namespace}</TableCell>
|
||||
<TableCell>{rb.role}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{rb.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(rb),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", rb }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace={ns}
|
||||
resourceType="rolebindings"
|
||||
resourceName={activeModal.rb.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="RoleBinding"
|
||||
resourceName={activeModal.rb.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,67 @@
|
||||
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 { RoleInfo } from "@/lib/tauriCommands";
|
||||
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface RoleListProps {
|
||||
roles: RoleInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: string;
|
||||
_clusterId?: string;
|
||||
namespace?: string;
|
||||
_namespace?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function RoleList({ roles, _clusterId, _namespace }: RoleListProps) {
|
||||
type ActiveModal =
|
||||
| { type: "edit"; role: RoleInfo; yaml: string }
|
||||
| { type: "delete"; role: RoleInfo }
|
||||
| null;
|
||||
|
||||
export function RoleList({
|
||||
roles,
|
||||
clusterId,
|
||||
_clusterId,
|
||||
namespace,
|
||||
_namespace,
|
||||
onRefresh,
|
||||
}: RoleListProps) {
|
||||
const cid = clusterId ?? _clusterId ?? "";
|
||||
const ns = namespace ?? _namespace ?? "";
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openEdit = async (role: RoleInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const yaml = await getResourceYamlCmd(cid, "roles", ns, role.name);
|
||||
setActiveModal({ type: "edit", role, 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, "roles", ns, activeModal.role.name);
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -17,12 +69,13 @@ export function RoleList({ roles, _clusterId, _namespace }: RoleListProps) {
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No roles found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -32,11 +85,52 @@ export function RoleList({ roles, _clusterId, _namespace }: RoleListProps) {
|
||||
<TableCell className="font-medium">{role.name}</TableCell>
|
||||
<TableCell>{role.namespace}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{role.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(role),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", role }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace={ns}
|
||||
resourceType="roles"
|
||||
resourceName={activeModal.role.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Role"
|
||||
resourceName={activeModal.role.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
42
src/components/Kubernetes/RuntimeClassList.tsx
Normal file
42
src/components/Kubernetes/RuntimeClassList.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import type { RuntimeClassInfo } from "@/lib/tauriCommands";
|
||||
|
||||
interface RuntimeClassListProps {
|
||||
items: RuntimeClassInfo[];
|
||||
clusterId: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export function RuntimeClassList({ items }: RuntimeClassListProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Handler</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
No runtime classes found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((rc) => (
|
||||
<TableRow key={rc.name}>
|
||||
<TableCell className="font-medium">{rc.name}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{rc.handler}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{rc.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/Kubernetes/ScaleModal.tsx
Normal file
102
src/components/Kubernetes/ScaleModal.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui";
|
||||
import { Button } from "@/components/ui";
|
||||
import { Input } from "@/components/ui";
|
||||
import { Label } from "@/components/ui";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface ScaleModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
resourceType: string;
|
||||
resourceName: string;
|
||||
currentReplicas: number;
|
||||
onScale: (replicas: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ScaleModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
resourceType,
|
||||
resourceName,
|
||||
currentReplicas,
|
||||
onScale,
|
||||
}: ScaleModalProps) {
|
||||
const [value, setValue] = React.useState(String(currentReplicas));
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setValue(String(currentReplicas));
|
||||
setError(null);
|
||||
}
|
||||
}, [open, currentReplicas]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const replicas = parseInt(value, 10);
|
||||
if (isNaN(replicas) || replicas < 0) {
|
||||
setError("Enter a valid non-negative integer.");
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onScale(replicas);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Scale {resourceType}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scaling <span className="font-mono text-foreground">{resourceName}</span>
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="scale-replicas">Replica Count</Label>
|
||||
<Input
|
||||
id="scale-replicas"
|
||||
type="number"
|
||||
min={0}
|
||||
value={value}
|
||||
onChange={(e) => { setValue(e.target.value); setError(null); }}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Scaling...
|
||||
</>
|
||||
) : (
|
||||
"Scale"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,67 @@
|
||||
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 { SecretInfo } from "@/lib/tauriCommands";
|
||||
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface SecretListProps {
|
||||
secrets: SecretInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: string;
|
||||
_clusterId?: string;
|
||||
namespace?: string;
|
||||
_namespace?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function SecretList({ secrets, _clusterId, _namespace }: SecretListProps) {
|
||||
type ActiveModal =
|
||||
| { type: "edit"; secret: SecretInfo; yaml: string }
|
||||
| { type: "delete"; secret: SecretInfo }
|
||||
| null;
|
||||
|
||||
export function SecretList({
|
||||
secrets,
|
||||
clusterId,
|
||||
_clusterId,
|
||||
namespace,
|
||||
_namespace,
|
||||
onRefresh,
|
||||
}: SecretListProps) {
|
||||
const cid = clusterId ?? _clusterId ?? "";
|
||||
const ns = namespace ?? _namespace ?? "";
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openEdit = async (secret: SecretInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const yaml = await getResourceYamlCmd(cid, "secrets", ns, secret.name);
|
||||
setActiveModal({ type: "edit", secret, 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, "secrets", ns, activeModal.secret.name);
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -38,7 +90,21 @@ export function SecretList({ secrets, _clusterId, _namespace }: SecretListProps)
|
||||
<TableCell>{secret.data_keys}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{secret.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="text-sm">View/Edit</span>
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(secret),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", secret }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@ -46,5 +112,29 @@ export function SecretList({ secrets, _clusterId, _namespace }: SecretListProps)
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace={ns}
|
||||
resourceType="secrets"
|
||||
resourceName={activeModal.secret.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Secret"
|
||||
resourceName={activeModal.secret.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,67 @@
|
||||
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 { ServiceAccountInfo } from "@/lib/tauriCommands";
|
||||
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface ServiceAccountListProps {
|
||||
serviceAccounts: ServiceAccountInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: string;
|
||||
_clusterId?: string;
|
||||
namespace?: string;
|
||||
_namespace?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function ServiceAccountList({ serviceAccounts, _clusterId, _namespace }: ServiceAccountListProps) {
|
||||
type ActiveModal =
|
||||
| { type: "edit"; sa: ServiceAccountInfo; yaml: string }
|
||||
| { type: "delete"; sa: ServiceAccountInfo }
|
||||
| null;
|
||||
|
||||
export function ServiceAccountList({
|
||||
serviceAccounts,
|
||||
clusterId,
|
||||
_clusterId,
|
||||
namespace,
|
||||
_namespace,
|
||||
onRefresh,
|
||||
}: ServiceAccountListProps) {
|
||||
const cid = clusterId ?? _clusterId ?? "";
|
||||
const ns = namespace ?? _namespace ?? "";
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openEdit = async (sa: ServiceAccountInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const yaml = await getResourceYamlCmd(cid, "serviceaccounts", ns, sa.name);
|
||||
setActiveModal({ type: "edit", sa, 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, "serviceaccounts", ns, activeModal.sa.name);
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -18,12 +70,13 @@ export function ServiceAccountList({ serviceAccounts, _clusterId, _namespace }:
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Secrets</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serviceAccounts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No service accounts found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -34,11 +87,52 @@ export function ServiceAccountList({ serviceAccounts, _clusterId, _namespace }:
|
||||
<TableCell>{sa.namespace}</TableCell>
|
||||
<TableCell>{sa.secrets}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{sa.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(sa),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", sa }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={cid}
|
||||
namespace={ns}
|
||||
resourceType="serviceaccounts"
|
||||
resourceName={activeModal.sa.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="ServiceAccount"
|
||||
resourceName={activeModal.sa.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,30 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Badge } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { ServiceInfo } from "@/lib/tauriCommands";
|
||||
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface ServiceListProps {
|
||||
services: ServiceInfo[];
|
||||
clusterId: string;
|
||||
namespace: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function ServiceList({ services, clusterId: _clusterId, namespace: _namespace }: ServiceListProps) {
|
||||
type ActiveModal =
|
||||
| { type: "edit"; svc: ServiceInfo; yaml: string }
|
||||
| { type: "delete"; svc: ServiceInfo }
|
||||
| null;
|
||||
|
||||
export function ServiceList({ services, clusterId, namespace, onRefresh }: ServiceListProps) {
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const getServiceTypeColor = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "clusterip":
|
||||
@ -25,7 +40,33 @@ export function ServiceList({ services, clusterId: _clusterId, namespace: _names
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = async (svc: ServiceInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const yaml = await getResourceYamlCmd(clusterId, "services", namespace, svc.name);
|
||||
setActiveModal({ type: "edit", svc, 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, "services", namespace, activeModal.svc.name);
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -36,12 +77,13 @@ export function ServiceList({ services, clusterId: _clusterId, namespace: _names
|
||||
<TableHead>External IP</TableHead>
|
||||
<TableHead>Ports</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No services found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -70,11 +112,52 @@ export function ServiceList({ services, clusterId: _clusterId, namespace: _names
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{service.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(service),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", svc: service }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
resourceType="services"
|
||||
resourceName={activeModal.svc.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Service"
|
||||
resourceName={activeModal.svc.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
137
src/components/Kubernetes/ShellExecModal.tsx
Normal file
137
src/components/Kubernetes/ShellExecModal.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
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 { Terminal, Loader2 } from "lucide-react";
|
||||
import { execPodCmd } from "@/lib/tauriCommands";
|
||||
|
||||
interface ShellExecModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
clusterId: string;
|
||||
namespace: string;
|
||||
podName: string;
|
||||
containers: string[];
|
||||
}
|
||||
|
||||
const SHELLS = [
|
||||
{ label: "bash", value: "/bin/bash" },
|
||||
{ label: "sh", value: "/bin/sh" },
|
||||
{ label: "ash", value: "/bin/ash" },
|
||||
];
|
||||
|
||||
export function ShellExecModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
clusterId,
|
||||
namespace,
|
||||
podName,
|
||||
containers,
|
||||
}: ShellExecModalProps) {
|
||||
const [selectedContainer, setSelectedContainer] = React.useState("");
|
||||
const [selectedShell, setSelectedShell] = React.useState("/bin/bash");
|
||||
const [output, setOutput] = React.useState("");
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedContainer(containers[0] ?? "");
|
||||
setOutput("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open, containers]);
|
||||
|
||||
const handleExec = async () => {
|
||||
if (!selectedContainer) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await execPodCmd(
|
||||
clusterId,
|
||||
namespace,
|
||||
podName,
|
||||
selectedContainer,
|
||||
selectedShell,
|
||||
selectedShell
|
||||
);
|
||||
const combined = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
setOutput(combined || `Exited with code ${result.exit_code ?? "unknown"}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Exec — <span className="font-mono">{podName}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={selectedContainer} onValueChange={setSelectedContainer}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select container" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedShell} onValueChange={setSelectedShell}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="Shell" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHELLS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExec}
|
||||
disabled={!selectedContainer || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
Exec
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<pre className="max-h-[50vh] overflow-auto rounded-md bg-black p-3 font-mono text-xs text-green-400 whitespace-pre-wrap break-all">
|
||||
{output || "Select a container and shell, then click Exec."}
|
||||
</pre>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,78 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Scale, RotateCcw, Pencil, Trash2 } from "lucide-react";
|
||||
import type { StatefulSetInfo } from "@/lib/tauriCommands";
|
||||
import {
|
||||
scaleStatefulsetCmd,
|
||||
restartStatefulsetCmd,
|
||||
deleteResourceCmd,
|
||||
getResourceYamlCmd,
|
||||
} from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { ScaleModal } from "./ScaleModal";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface StatefulSetListProps {
|
||||
statefulsets: StatefulSetInfo[];
|
||||
clusterId: string;
|
||||
namespace: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function StatefulSetList({ statefulsets, clusterId: _clusterId, namespace: _namespace }: StatefulSetListProps) {
|
||||
type ActiveModal =
|
||||
| { type: "scale"; ss: StatefulSetInfo }
|
||||
| { type: "restart"; ss: StatefulSetInfo }
|
||||
| { type: "edit"; ss: StatefulSetInfo; yaml: string }
|
||||
| { type: "delete"; ss: StatefulSetInfo }
|
||||
| null;
|
||||
|
||||
export function StatefulSetList({ statefulsets, clusterId, namespace, onRefresh }: StatefulSetListProps) {
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [isActing, setIsActing] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openEdit = async (ss: StatefulSetInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const yaml = await getResourceYamlCmd(clusterId, "statefulsets", namespace, ss.name);
|
||||
setActiveModal({ type: "edit", ss, yaml });
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
if (activeModal?.type !== "restart") return;
|
||||
setIsActing(true);
|
||||
try {
|
||||
await restartStatefulsetCmd(clusterId, namespace, activeModal.ss.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, "statefulsets", namespace, activeModal.ss.name);
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
} finally {
|
||||
setIsActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -18,12 +81,13 @@ export function StatefulSetList({ statefulsets, clusterId: _clusterId, namespace
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Replicas</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{statefulsets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No statefulsets found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -34,11 +98,90 @@ export function StatefulSetList({ statefulsets, clusterId: _clusterId, namespace
|
||||
<TableCell>{ss.ready}</TableCell>
|
||||
<TableCell>{ss.replicas}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{ss.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Scale",
|
||||
icon: Scale,
|
||||
onClick: () => setActiveModal({ type: "scale", ss }),
|
||||
},
|
||||
{
|
||||
label: "Restart",
|
||||
icon: RotateCcw,
|
||||
onClick: () => setActiveModal({ type: "restart", ss }),
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(ss),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", ss }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "scale" && (
|
||||
<ScaleModal
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="StatefulSet"
|
||||
resourceName={activeModal.ss.name}
|
||||
currentReplicas={activeModal.ss.replicas}
|
||||
onScale={(replicas) =>
|
||||
scaleStatefulsetCmd(clusterId, namespace, activeModal.ss.name, replicas).then(() => {
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "restart" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="StatefulSet"
|
||||
resourceName={activeModal.ss.name}
|
||||
isLoading={isActing}
|
||||
onConfirm={handleRestart}
|
||||
variant="delete"
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
resourceType="statefulsets"
|
||||
resourceName={activeModal.ss.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="StatefulSet"
|
||||
resourceName={activeModal.ss.name}
|
||||
isLoading={isActing}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,56 @@
|
||||
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 { StorageClassInfo } from "@/lib/tauriCommands";
|
||||
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface StorageClassListProps {
|
||||
storageclasses: StorageClassInfo[];
|
||||
clusterId: string;
|
||||
namespace: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function StorageClassList({ storageclasses }: StorageClassListProps) {
|
||||
type ActiveModal =
|
||||
| { type: "edit"; sc: StorageClassInfo; yaml: string }
|
||||
| { type: "delete"; sc: StorageClassInfo }
|
||||
| null;
|
||||
|
||||
export function StorageClassList({ storageclasses, clusterId, onRefresh }: StorageClassListProps) {
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openEdit = async (sc: StorageClassInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const yaml = await getResourceYamlCmd(clusterId, "storageclasses", "", sc.name);
|
||||
setActiveModal({ type: "edit", sc, 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, "storageclasses", "", activeModal.sc.name);
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -20,12 +61,13 @@ export function StorageClassList({ storageclasses }: StorageClassListProps) {
|
||||
<TableHead>Volume Binding Mode</TableHead>
|
||||
<TableHead>Expand</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storageclasses.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No storage classes found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -38,11 +80,52 @@ export function StorageClassList({ storageclasses }: StorageClassListProps) {
|
||||
<TableCell className="text-sm">{sc.volume_binding_mode}</TableCell>
|
||||
<TableCell className="text-sm">{sc.allow_volume_expansion ? "Yes" : "No"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{sc.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(sc),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", sc }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace=""
|
||||
resourceType="storageclasses"
|
||||
resourceName={activeModal.sc.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "delete" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="StorageClass"
|
||||
resourceName={activeModal.sc.name}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
42
src/components/Kubernetes/ValidatingWebhookList.tsx
Normal file
42
src/components/Kubernetes/ValidatingWebhookList.tsx
Normal file
@ -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 ValidatingWebhookListProps {
|
||||
items: WebhookConfigInfo[];
|
||||
clusterId: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export function ValidatingWebhookList({ items }: ValidatingWebhookListProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Webhooks</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
No validating webhook configurations found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((wh) => (
|
||||
<TableRow key={wh.name}>
|
||||
<TableCell className="font-medium">{wh.name}</TableCell>
|
||||
<TableCell className="text-sm">{wh.webhooks}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{wh.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/components/Kubernetes/WorkloadOverview.tsx
Normal file
148
src/components/Kubernetes/WorkloadOverview.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import React from "react";
|
||||
import { Layers, Box, Server, Activity } from "lucide-react";
|
||||
import type {
|
||||
PodInfo,
|
||||
DeploymentInfo,
|
||||
StatefulSetInfo,
|
||||
DaemonSetInfo,
|
||||
JobInfo,
|
||||
CronJobInfo,
|
||||
} from "@/lib/tauriCommands";
|
||||
|
||||
interface WorkloadOverviewProps {
|
||||
clusterId: string;
|
||||
resources: {
|
||||
pods: PodInfo[];
|
||||
deployments: DeploymentInfo[];
|
||||
statefulsets: StatefulSetInfo[];
|
||||
daemonsets: DaemonSetInfo[];
|
||||
jobs: JobInfo[];
|
||||
cronjobs: CronJobInfo[];
|
||||
};
|
||||
}
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
subtitle?: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
function SummaryCard({ title, value, subtitle, icon }: SummaryCardProps) {
|
||||
return (
|
||||
<div className="bg-card rounded-lg p-4 border">
|
||||
<div className="flex items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkloadOverview({ resources }: WorkloadOverviewProps) {
|
||||
const { pods, deployments, statefulsets, daemonsets, jobs, cronjobs } = resources;
|
||||
|
||||
const runningPods = pods.filter((p) => p.status === "Running").length;
|
||||
const pendingPods = pods.filter((p) => p.status === "Pending").length;
|
||||
const failedPods = pods.filter((p) => p.status === "Failed").length;
|
||||
|
||||
const readyDeployments = deployments.filter((d) => d.ready === `${d.replicas}/${d.replicas}`).length;
|
||||
|
||||
const readyStatefulSets = statefulsets.filter((s) => {
|
||||
const parts = s.ready.split("/");
|
||||
return parts.length === 2 && parts[0] === parts[1];
|
||||
}).length;
|
||||
|
||||
const healthyDaemonSets = daemonsets.filter(
|
||||
(ds) => ds.desired === ds.ready
|
||||
).length;
|
||||
|
||||
const completedJobs = jobs.filter((j) => {
|
||||
const parts = j.completions.split("/");
|
||||
return parts.length === 2 && parts[0] === parts[1];
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto space-y-6 p-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Workload Overview</h2>
|
||||
<p className="text-muted-foreground text-sm mt-0.5">
|
||||
Summary of all workload resources in the selected namespace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<SummaryCard
|
||||
title="Pods"
|
||||
value={pods.length}
|
||||
subtitle={`Running: ${runningPods} · Pending: ${pendingPods} · Failed: ${failedPods}`}
|
||||
icon={<Box className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Deployments"
|
||||
value={deployments.length}
|
||||
subtitle={`Ready: ${readyDeployments}/${deployments.length}`}
|
||||
icon={<Layers className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="StatefulSets"
|
||||
value={statefulsets.length}
|
||||
subtitle={`Ready: ${readyStatefulSets}/${statefulsets.length}`}
|
||||
icon={<Server className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="DaemonSets"
|
||||
value={daemonsets.length}
|
||||
subtitle={`Healthy: ${healthyDaemonSets}/${daemonsets.length}`}
|
||||
icon={<Activity className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Jobs"
|
||||
value={jobs.length}
|
||||
subtitle={`Completed: ${completedJobs}/${jobs.length}`}
|
||||
icon={<Activity className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Cron Jobs"
|
||||
value={cronjobs.length}
|
||||
subtitle={cronjobs.length > 0 ? `Active: ${cronjobs.reduce((acc, cj) => acc + cj.active, 0)}` : undefined}
|
||||
icon={<Activity className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pods.length > 0 && (
|
||||
<div className="bg-card rounded-lg border">
|
||||
<div className="border-b px-6 py-4">
|
||||
<h3 className="font-semibold">Pod Status Breakdown</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-green-500" />
|
||||
<span>Running: {runningPods}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<span>Pending: {pendingPods}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-red-500" />
|
||||
<span>Failed: {failedPods}</span>
|
||||
</div>
|
||||
{pods.length - runningPods - pendingPods - failedPods > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-gray-400" />
|
||||
<span>Other: {pods.length - runningPods - pendingPods - failedPods}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -49,3 +49,15 @@ export { StorageClassList } from "./StorageClassList";
|
||||
export { NetworkPolicyList } from "./NetworkPolicyList";
|
||||
export { ResourceQuotaList } from "./ResourceQuotaList";
|
||||
export { LimitRangeList } from "./LimitRangeList";
|
||||
export { ReplicationControllerList } from "./ReplicationControllerList";
|
||||
export { PodDisruptionBudgetList } from "./PodDisruptionBudgetList";
|
||||
export { PriorityClassList } from "./PriorityClassList";
|
||||
export { RuntimeClassList } from "./RuntimeClassList";
|
||||
export { LeaseList } from "./LeaseList";
|
||||
export { MutatingWebhookList } from "./MutatingWebhookList";
|
||||
export { ValidatingWebhookList } from "./ValidatingWebhookList";
|
||||
export { EndpointList } from "./EndpointList";
|
||||
export { EndpointSliceList } from "./EndpointSliceList";
|
||||
export { IngressClassList } from "./IngressClassList";
|
||||
export { NamespaceList } from "./NamespaceList";
|
||||
export { WorkloadOverview } from "./WorkloadOverview";
|
||||
|
||||
@ -10,6 +10,10 @@ import {
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Package,
|
||||
Settings2,
|
||||
Box,
|
||||
Bell,
|
||||
Puzzle,
|
||||
} from "lucide-react";
|
||||
import { useKubernetesStore } from "@/stores/kubernetesStore";
|
||||
import {
|
||||
@ -54,6 +58,18 @@ import {
|
||||
NetworkPolicyList,
|
||||
ResourceQuotaList,
|
||||
LimitRangeList,
|
||||
ReplicationControllerList,
|
||||
PodDisruptionBudgetList,
|
||||
PriorityClassList,
|
||||
RuntimeClassList,
|
||||
LeaseList,
|
||||
MutatingWebhookList,
|
||||
ValidatingWebhookList,
|
||||
EndpointList,
|
||||
EndpointSliceList,
|
||||
IngressClassList,
|
||||
NamespaceList,
|
||||
WorkloadOverview,
|
||||
} from "@/components/Kubernetes";
|
||||
import type {
|
||||
KubeconfigInfo,
|
||||
@ -84,6 +100,19 @@ import type {
|
||||
NetworkPolicyInfo,
|
||||
ResourceQuotaInfo,
|
||||
LimitRangeInfo,
|
||||
ReplicationControllerInfo,
|
||||
PodDisruptionBudgetInfo,
|
||||
PriorityClassInfo,
|
||||
RuntimeClassInfo,
|
||||
LeaseInfo,
|
||||
WebhookConfigInfo,
|
||||
EndpointInfo,
|
||||
EndpointSliceInfo,
|
||||
IngressClassInfo,
|
||||
NamespaceResourceInfo,
|
||||
HelmChart,
|
||||
HelmRelease,
|
||||
CrdInfo,
|
||||
} from "@/lib/tauriCommands";
|
||||
import {
|
||||
listKubeconfigsCmd,
|
||||
@ -119,108 +148,181 @@ import {
|
||||
listNetworkpoliciesCmd,
|
||||
listResourcequotasCmd,
|
||||
listLimitrangesCmd,
|
||||
listReplicationcontrollersCmd,
|
||||
listPoddisruptionbudgetsCmd,
|
||||
listPriorityclassesCmd,
|
||||
listRuntimeclassesCmd,
|
||||
listLeasesCmd,
|
||||
listMutatingwebhookconfigurationsCmd,
|
||||
listValidatingwebhookconfigurationsCmd,
|
||||
listEndpointsCmd,
|
||||
listEndpointslicesCmd,
|
||||
listIngressclassesCmd,
|
||||
listNamespacesResourceCmd,
|
||||
helmSearchRepoCmd,
|
||||
helmListReleasesCmd,
|
||||
listCrdsCmd,
|
||||
} from "@/lib/tauriCommands";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ActiveSection =
|
||||
| "overview"
|
||||
| "cluster_overview"
|
||||
| "nodes"
|
||||
| "workloads_overview"
|
||||
| "pods"
|
||||
| "deployments"
|
||||
| "daemonsets"
|
||||
| "statefulsets"
|
||||
| "replicasets"
|
||||
| "replicationcontrollers"
|
||||
| "jobs"
|
||||
| "cronjobs"
|
||||
| "services"
|
||||
| "ingresses"
|
||||
| "configmaps"
|
||||
| "secrets"
|
||||
| "resourcequotas"
|
||||
| "limitranges"
|
||||
| "hpas"
|
||||
| "poddisruptionbudgets"
|
||||
| "priorityclasses"
|
||||
| "runtimeclasses"
|
||||
| "leases"
|
||||
| "mutatingwebhooks"
|
||||
| "validatingwebhooks"
|
||||
| "services"
|
||||
| "endpointslices"
|
||||
| "endpoints"
|
||||
| "ingresses"
|
||||
| "ingressclasses"
|
||||
| "networkpolicies"
|
||||
| "portforwarding"
|
||||
| "pvcs"
|
||||
| "pvs"
|
||||
| "serviceaccounts"
|
||||
| "roles"
|
||||
| "clusterroles"
|
||||
| "rolebindings"
|
||||
| "clusterrolebindings"
|
||||
| "nodes"
|
||||
| "events"
|
||||
| "portforwarding"
|
||||
| "storageclasses"
|
||||
| "networkpolicies"
|
||||
| "resourcequotas"
|
||||
| "limitranges";
|
||||
| "namespaces"
|
||||
| "events"
|
||||
| "helm_charts"
|
||||
| "helm_releases"
|
||||
| "serviceaccounts"
|
||||
| "clusterroles"
|
||||
| "roles"
|
||||
| "clusterrolebindings"
|
||||
| "rolebindings"
|
||||
| "crds";
|
||||
|
||||
interface NavItem {
|
||||
id: ActiveSection;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
interface NavGroup {
|
||||
type: "group";
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
interface NavTopLevel {
|
||||
type: "toplevel";
|
||||
id: ActiveSection;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
type NavEntry = NavGroup | NavTopLevel;
|
||||
|
||||
// ─── Nav structure ────────────────────────────────────────────────────────────
|
||||
|
||||
const NAV_SECTIONS: NavSection[] = [
|
||||
const NAV_ENTRIES: NavEntry[] = [
|
||||
{ type: "toplevel", id: "cluster_overview", label: "Cluster", icon: Server },
|
||||
{ type: "toplevel", id: "nodes", label: "Nodes", icon: Server },
|
||||
{
|
||||
type: "group",
|
||||
label: "Workloads",
|
||||
icon: Layers,
|
||||
items: [
|
||||
{ id: "workloads_overview", label: "Overview" },
|
||||
{ id: "pods", label: "Pods" },
|
||||
{ id: "deployments", label: "Deployments" },
|
||||
{ id: "daemonsets", label: "Daemon Sets" },
|
||||
{ id: "statefulsets", label: "Stateful Sets" },
|
||||
{ id: "replicasets", label: "Replica Sets" },
|
||||
{ id: "replicationcontrollers", label: "Replication Controllers" },
|
||||
{ id: "jobs", label: "Jobs" },
|
||||
{ id: "cronjobs", label: "Cron Jobs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Services & Networking",
|
||||
icon: Network,
|
||||
items: [
|
||||
{ id: "services", label: "Services" },
|
||||
{ id: "ingresses", label: "Ingresses" },
|
||||
{ id: "networkpolicies", label: "Network Policies" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Config & Storage",
|
||||
icon: Database,
|
||||
type: "group",
|
||||
label: "Config",
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{ id: "configmaps", label: "Config Maps" },
|
||||
{ id: "secrets", label: "Secrets" },
|
||||
{ id: "hpas", label: "Horizontal Pod Autoscalers" },
|
||||
{ id: "pvcs", label: "Persistent Volume Claims" },
|
||||
{ id: "pvs", label: "Persistent Volumes" },
|
||||
{ id: "storageclasses", label: "Storage Classes" },
|
||||
{ id: "resourcequotas", label: "Resource Quotas" },
|
||||
{ id: "limitranges", label: "Limit Ranges" },
|
||||
{ id: "hpas", label: "Horizontal Pod Autoscalers" },
|
||||
{ id: "poddisruptionbudgets", label: "Pod Disruption Budgets" },
|
||||
{ id: "priorityclasses", label: "Priority Classes" },
|
||||
{ id: "runtimeclasses", label: "Runtime Classes" },
|
||||
{ id: "leases", label: "Leases" },
|
||||
{ id: "mutatingwebhooks", label: "Mutating Webhook Configs" },
|
||||
{ id: "validatingwebhooks", label: "Validating Webhook Configs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
label: "Network",
|
||||
icon: Network,
|
||||
items: [
|
||||
{ id: "services", label: "Services" },
|
||||
{ id: "endpointslices", label: "Endpoint Slices" },
|
||||
{ id: "endpoints", label: "Endpoints" },
|
||||
{ id: "ingresses", label: "Ingresses" },
|
||||
{ id: "ingressclasses", label: "Ingress Classes" },
|
||||
{ id: "networkpolicies", label: "Network Policies" },
|
||||
{ id: "portforwarding", label: "Port Forwarding" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
label: "Storage",
|
||||
icon: Database,
|
||||
items: [
|
||||
{ id: "pvcs", label: "Persistent Volume Claims" },
|
||||
{ id: "pvs", label: "Persistent Volumes" },
|
||||
{ id: "storageclasses", label: "Storage Classes" },
|
||||
],
|
||||
},
|
||||
{ type: "toplevel", id: "namespaces", label: "Namespaces", icon: Box },
|
||||
{ type: "toplevel", id: "events", label: "Events", icon: Bell },
|
||||
{
|
||||
type: "group",
|
||||
label: "Helm",
|
||||
icon: Package,
|
||||
items: [
|
||||
{ id: "helm_charts", label: "Charts" },
|
||||
{ id: "helm_releases", label: "Releases" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
label: "Access Control",
|
||||
icon: Shield,
|
||||
items: [
|
||||
{ id: "serviceaccounts", label: "Service Accounts" },
|
||||
{ id: "roles", label: "Roles" },
|
||||
{ id: "clusterroles", label: "Cluster Roles" },
|
||||
{ id: "rolebindings", label: "Role Bindings" },
|
||||
{ id: "roles", label: "Roles" },
|
||||
{ id: "clusterrolebindings", label: "Cluster Role Bindings" },
|
||||
{ id: "rolebindings", label: "Role Bindings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Cluster",
|
||||
icon: Server,
|
||||
type: "group",
|
||||
label: "Custom Resources",
|
||||
icon: Puzzle,
|
||||
items: [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "nodes", label: "Nodes" },
|
||||
{ id: "events", label: "Events" },
|
||||
{ id: "portforwarding", label: "Port Forwarding" },
|
||||
{ id: "crds", label: "Definitions" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -253,6 +355,20 @@ interface ResourceData {
|
||||
networkpolicies: NetworkPolicyInfo[];
|
||||
resourcequotas: ResourceQuotaInfo[];
|
||||
limitranges: LimitRangeInfo[];
|
||||
replicationcontrollers: ReplicationControllerInfo[];
|
||||
poddisruptionbudgets: PodDisruptionBudgetInfo[];
|
||||
priorityclasses: PriorityClassInfo[];
|
||||
runtimeclasses: RuntimeClassInfo[];
|
||||
leases: LeaseInfo[];
|
||||
mutatingwebhooks: WebhookConfigInfo[];
|
||||
validatingwebhooks: WebhookConfigInfo[];
|
||||
endpoints: EndpointInfo[];
|
||||
endpointslices: EndpointSliceInfo[];
|
||||
ingressclasses: IngressClassInfo[];
|
||||
namespaces_resource: NamespaceResourceInfo[];
|
||||
helm_charts: HelmChart[];
|
||||
helm_releases: HelmRelease[];
|
||||
crds: CrdInfo[];
|
||||
}
|
||||
|
||||
const EMPTY_RESOURCES: ResourceData = {
|
||||
@ -281,6 +397,20 @@ const EMPTY_RESOURCES: ResourceData = {
|
||||
networkpolicies: [],
|
||||
resourcequotas: [],
|
||||
limitranges: [],
|
||||
replicationcontrollers: [],
|
||||
poddisruptionbudgets: [],
|
||||
priorityclasses: [],
|
||||
runtimeclasses: [],
|
||||
leases: [],
|
||||
mutatingwebhooks: [],
|
||||
validatingwebhooks: [],
|
||||
endpoints: [],
|
||||
endpointslices: [],
|
||||
ingressclasses: [],
|
||||
namespaces_resource: [],
|
||||
helm_charts: [],
|
||||
helm_releases: [],
|
||||
crds: [],
|
||||
};
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
@ -293,20 +423,21 @@ export function KubernetesPage() {
|
||||
const [namespaces, setNamespaces] = useState<NamespaceInfo[]>([]);
|
||||
const [portForwards, setPortForwards] = useState<PortForwardResponse[]>([]);
|
||||
const [resources, setResources] = useState<ResourceData>(EMPTY_RESOURCES);
|
||||
const [activeSection, setActiveSection] = useState<ActiveSection>("overview");
|
||||
const [activeSection, setActiveSection] = useState<ActiveSection>("cluster_overview");
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
Workloads: true,
|
||||
"Services & Networking": true,
|
||||
"Config & Storage": true,
|
||||
Config: true,
|
||||
Network: true,
|
||||
Storage: true,
|
||||
Helm: false,
|
||||
"Access Control": true,
|
||||
Cluster: true,
|
||||
"Custom Resources": false,
|
||||
});
|
||||
const [isLoadingResources, setIsLoadingResources] = useState(false);
|
||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||
const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false);
|
||||
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
|
||||
|
||||
// Track the last loaded section to avoid redundant fetches
|
||||
const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null);
|
||||
|
||||
// ── Initial data load ──────────────────────────────────────────────────────
|
||||
@ -350,7 +481,13 @@ export function KubernetesPage() {
|
||||
|
||||
const loadResourceData = useCallback(
|
||||
async (section: ActiveSection, clusterId: string, namespace: string) => {
|
||||
if (section === "overview" || section === "portforwarding") return;
|
||||
if (
|
||||
section === "cluster_overview" ||
|
||||
section === "portforwarding" ||
|
||||
section === "workloads_overview"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ns = namespace === "all" ? "" : namespace;
|
||||
|
||||
@ -358,8 +495,6 @@ export function KubernetesPage() {
|
||||
try {
|
||||
switch (section) {
|
||||
case "pods":
|
||||
setResources((r) => ({ ...r, pods: [] }));
|
||||
setResources((r) => ({ ...r }));
|
||||
await listPodsCmd(clusterId, ns).then((data) =>
|
||||
setResources((r) => ({ ...r, pods: data }))
|
||||
);
|
||||
@ -384,6 +519,11 @@ export function KubernetesPage() {
|
||||
setResources((r) => ({ ...r, replicasets: data }))
|
||||
);
|
||||
break;
|
||||
case "replicationcontrollers":
|
||||
await listReplicationcontrollersCmd(clusterId, ns).then((data) =>
|
||||
setResources((r) => ({ ...r, replicationcontrollers: data }))
|
||||
);
|
||||
break;
|
||||
case "jobs":
|
||||
await listJobsCmd(clusterId, ns).then((data) =>
|
||||
setResources((r) => ({ ...r, jobs: data }))
|
||||
@ -484,6 +624,71 @@ export function KubernetesPage() {
|
||||
setResources((r) => ({ ...r, limitranges: data }))
|
||||
);
|
||||
break;
|
||||
case "poddisruptionbudgets":
|
||||
await listPoddisruptionbudgetsCmd(clusterId, ns).then((data) =>
|
||||
setResources((r) => ({ ...r, poddisruptionbudgets: data }))
|
||||
);
|
||||
break;
|
||||
case "priorityclasses":
|
||||
await listPriorityclassesCmd(clusterId).then((data) =>
|
||||
setResources((r) => ({ ...r, priorityclasses: data }))
|
||||
);
|
||||
break;
|
||||
case "runtimeclasses":
|
||||
await listRuntimeclassesCmd(clusterId).then((data) =>
|
||||
setResources((r) => ({ ...r, runtimeclasses: data }))
|
||||
);
|
||||
break;
|
||||
case "leases":
|
||||
await listLeasesCmd(clusterId, ns).then((data) =>
|
||||
setResources((r) => ({ ...r, leases: data }))
|
||||
);
|
||||
break;
|
||||
case "mutatingwebhooks":
|
||||
await listMutatingwebhookconfigurationsCmd(clusterId).then((data) =>
|
||||
setResources((r) => ({ ...r, mutatingwebhooks: data }))
|
||||
);
|
||||
break;
|
||||
case "validatingwebhooks":
|
||||
await listValidatingwebhookconfigurationsCmd(clusterId).then((data) =>
|
||||
setResources((r) => ({ ...r, validatingwebhooks: data }))
|
||||
);
|
||||
break;
|
||||
case "endpoints":
|
||||
await listEndpointsCmd(clusterId, ns).then((data) =>
|
||||
setResources((r) => ({ ...r, endpoints: data }))
|
||||
);
|
||||
break;
|
||||
case "endpointslices":
|
||||
await listEndpointslicesCmd(clusterId, ns).then((data) =>
|
||||
setResources((r) => ({ ...r, endpointslices: data }))
|
||||
);
|
||||
break;
|
||||
case "ingressclasses":
|
||||
await listIngressclassesCmd(clusterId).then((data) =>
|
||||
setResources((r) => ({ ...r, ingressclasses: data }))
|
||||
);
|
||||
break;
|
||||
case "namespaces":
|
||||
await listNamespacesResourceCmd(clusterId).then((data) =>
|
||||
setResources((r) => ({ ...r, namespaces_resource: data }))
|
||||
);
|
||||
break;
|
||||
case "helm_charts":
|
||||
await helmSearchRepoCmd(clusterId, "").then((data) =>
|
||||
setResources((r) => ({ ...r, helm_charts: data }))
|
||||
);
|
||||
break;
|
||||
case "helm_releases":
|
||||
await helmListReleasesCmd(clusterId, ns).then((data) =>
|
||||
setResources((r) => ({ ...r, helm_releases: data }))
|
||||
);
|
||||
break;
|
||||
case "crds":
|
||||
await listCrdsCmd(clusterId).then((data) =>
|
||||
setResources((r) => ({ ...r, crds: data }))
|
||||
);
|
||||
break;
|
||||
}
|
||||
lastLoadedRef.current = { section, clusterId, namespace };
|
||||
} catch (err) {
|
||||
@ -593,7 +798,7 @@ export function KubernetesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "overview") {
|
||||
if (activeSection === "cluster_overview") {
|
||||
return (
|
||||
<ClusterOverview
|
||||
clusterId={selectedClusterId}
|
||||
@ -602,6 +807,22 @@ export function KubernetesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "workloads_overview") {
|
||||
return (
|
||||
<WorkloadOverview
|
||||
clusterId={selectedClusterId}
|
||||
resources={{
|
||||
pods: resources.pods,
|
||||
deployments: resources.deployments,
|
||||
statefulsets: resources.statefulsets,
|
||||
daemonsets: resources.daemonsets,
|
||||
jobs: resources.jobs,
|
||||
cronjobs: resources.cronjobs,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "portforwarding") {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
@ -647,35 +868,37 @@ export function KubernetesPage() {
|
||||
case "statefulsets":
|
||||
return <StatefulSetList statefulsets={resources.statefulsets} clusterId={cid} namespace={ns} />;
|
||||
case "replicasets":
|
||||
return <ReplicaSetList replicaSets={resources.replicasets} _clusterId={cid} _namespace={ns} />;
|
||||
return <ReplicaSetList replicaSets={resources.replicasets} clusterId={cid} namespace={ns} />;
|
||||
case "replicationcontrollers":
|
||||
return <ReplicationControllerList items={resources.replicationcontrollers} clusterId={cid} namespace={ns} />;
|
||||
case "jobs":
|
||||
return <JobList jobs={resources.jobs} _clusterId={cid} _namespace={ns} />;
|
||||
return <JobList jobs={resources.jobs} clusterId={cid} namespace={ns} />;
|
||||
case "cronjobs":
|
||||
return <CronJobList cronJobs={resources.cronjobs} _clusterId={cid} _namespace={ns} />;
|
||||
return <CronJobList cronJobs={resources.cronjobs} clusterId={cid} namespace={ns} />;
|
||||
case "services":
|
||||
return <ServiceList services={resources.services} clusterId={cid} namespace={ns} />;
|
||||
case "ingresses":
|
||||
return <IngressList ingresses={resources.ingresses} _clusterId={cid} _namespace={ns} />;
|
||||
return <IngressList ingresses={resources.ingresses} clusterId={cid} namespace={ns} />;
|
||||
case "configmaps":
|
||||
return <ConfigMapList configmaps={resources.configmaps} clusterId={cid} namespace={ns} />;
|
||||
case "secrets":
|
||||
return <SecretList secrets={resources.secrets} _clusterId={cid} _namespace={ns} />;
|
||||
return <SecretList secrets={resources.secrets} clusterId={cid} namespace={ns} />;
|
||||
case "hpas":
|
||||
return <HPAList hpas={resources.hpas} _clusterId={cid} _namespace={ns} />;
|
||||
return <HPAList hpas={resources.hpas} clusterId={cid} namespace={ns} />;
|
||||
case "pvcs":
|
||||
return <PVCList pvcs={resources.pvcs} _clusterId={cid} _namespace={ns} />;
|
||||
return <PVCList pvcs={resources.pvcs} clusterId={cid} namespace={ns} />;
|
||||
case "pvs":
|
||||
return <PVList pvs={resources.pvs} _clusterId={cid} />;
|
||||
return <PVList pvs={resources.pvs} clusterId={cid} />;
|
||||
case "serviceaccounts":
|
||||
return <ServiceAccountList serviceAccounts={resources.serviceaccounts} _clusterId={cid} _namespace={ns} />;
|
||||
return <ServiceAccountList serviceAccounts={resources.serviceaccounts} clusterId={cid} namespace={ns} />;
|
||||
case "roles":
|
||||
return <RoleList roles={resources.roles} _clusterId={cid} _namespace={ns} />;
|
||||
return <RoleList roles={resources.roles} clusterId={cid} namespace={ns} />;
|
||||
case "clusterroles":
|
||||
return <ClusterRoleList clusterRoles={resources.clusterroles} _clusterId={cid} />;
|
||||
return <ClusterRoleList clusterRoles={resources.clusterroles} clusterId={cid} />;
|
||||
case "rolebindings":
|
||||
return <RoleBindingList roleBindings={resources.rolebindings} _clusterId={cid} _namespace={ns} />;
|
||||
return <RoleBindingList roleBindings={resources.rolebindings} clusterId={cid} namespace={ns} />;
|
||||
case "clusterrolebindings":
|
||||
return <ClusterRoleBindingList clusterRoleBindings={resources.clusterrolebindings} _clusterId={cid} />;
|
||||
return <ClusterRoleBindingList clusterRoleBindings={resources.clusterrolebindings} clusterId={cid} />;
|
||||
case "nodes":
|
||||
return <NodeList nodes={resources.nodes} clusterId={cid} />;
|
||||
case "events":
|
||||
@ -688,6 +911,142 @@ export function KubernetesPage() {
|
||||
return <ResourceQuotaList resourcequotas={resources.resourcequotas} clusterId={cid} namespace={ns} />;
|
||||
case "limitranges":
|
||||
return <LimitRangeList limitranges={resources.limitranges} clusterId={cid} namespace={ns} />;
|
||||
case "poddisruptionbudgets":
|
||||
return <PodDisruptionBudgetList items={resources.poddisruptionbudgets} clusterId={cid} namespace={ns} />;
|
||||
case "priorityclasses":
|
||||
return <PriorityClassList items={resources.priorityclasses} clusterId={cid} />;
|
||||
case "runtimeclasses":
|
||||
return <RuntimeClassList items={resources.runtimeclasses} clusterId={cid} />;
|
||||
case "leases":
|
||||
return <LeaseList items={resources.leases} clusterId={cid} namespace={ns} />;
|
||||
case "mutatingwebhooks":
|
||||
return <MutatingWebhookList items={resources.mutatingwebhooks} clusterId={cid} />;
|
||||
case "validatingwebhooks":
|
||||
return <ValidatingWebhookList items={resources.validatingwebhooks} clusterId={cid} />;
|
||||
case "endpoints":
|
||||
return <EndpointList items={resources.endpoints} clusterId={cid} namespace={ns} />;
|
||||
case "endpointslices":
|
||||
return <EndpointSliceList items={resources.endpointslices} clusterId={cid} namespace={ns} />;
|
||||
case "ingressclasses":
|
||||
return <IngressClassList items={resources.ingressclasses} clusterId={cid} />;
|
||||
case "namespaces":
|
||||
return <NamespaceList items={resources.namespaces_resource} clusterId={cid} />;
|
||||
case "helm_charts":
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Helm Charts</h2>
|
||||
{resources.helm_charts.length === 0 ? (
|
||||
<p className="text-muted-foreground">No charts found. Add a Helm repository to browse charts.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground text-left">
|
||||
<th className="px-4 py-3 font-medium">Name</th>
|
||||
<th className="px-4 py-3 font-medium">Repository</th>
|
||||
<th className="px-4 py-3 font-medium">Chart Version</th>
|
||||
<th className="px-4 py-3 font-medium">App Version</th>
|
||||
<th className="px-4 py-3 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resources.helm_charts.map((chart) => (
|
||||
<tr key={`${chart.repository}-${chart.name}`} className="border-b hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-medium">{chart.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{chart.repository}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{chart.chart_version}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{chart.app_version}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground truncate max-w-xs">{chart.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "helm_releases":
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Helm Releases</h2>
|
||||
{resources.helm_releases.length === 0 ? (
|
||||
<p className="text-muted-foreground">No Helm releases found in this namespace.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground text-left">
|
||||
<th className="px-4 py-3 font-medium">Name</th>
|
||||
<th className="px-4 py-3 font-medium">Namespace</th>
|
||||
<th className="px-4 py-3 font-medium">Chart</th>
|
||||
<th className="px-4 py-3 font-medium">App Version</th>
|
||||
<th className="px-4 py-3 font-medium">Status</th>
|
||||
<th className="px-4 py-3 font-medium">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resources.helm_releases.map((rel) => (
|
||||
<tr key={`${rel.namespace}-${rel.name}`} className="border-b hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-medium">{rel.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{rel.namespace}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{rel.chart} {rel.chart_version}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{rel.app_version}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
rel.status === "deployed"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
: rel.status === "failed"
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{rel.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{rel.updated}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "crds":
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Custom Resource Definitions</h2>
|
||||
{resources.crds.length === 0 ? (
|
||||
<p className="text-muted-foreground">No custom resource definitions found.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground text-left">
|
||||
<th className="px-4 py-3 font-medium">Name</th>
|
||||
<th className="px-4 py-3 font-medium">Group</th>
|
||||
<th className="px-4 py-3 font-medium">Version</th>
|
||||
<th className="px-4 py-3 font-medium">Kind</th>
|
||||
<th className="px-4 py-3 font-medium">Scope</th>
|
||||
<th className="px-4 py-3 font-medium">Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resources.crds.map((crd) => (
|
||||
<tr key={crd.name} className="border-b hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-medium font-mono text-xs">{crd.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{crd.group}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{crd.version}</td>
|
||||
<td className="px-4 py-3">{crd.kind}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{crd.scope}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{crd.age}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@ -776,19 +1135,38 @@ export function KubernetesPage() {
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 shrink-0 border-r bg-card overflow-y-auto flex flex-col">
|
||||
{NAV_SECTIONS.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const isExpanded = expandedSections[section.label] ?? true;
|
||||
{NAV_ENTRIES.map((entry) => {
|
||||
if (entry.type === "toplevel") {
|
||||
const Icon = entry.icon;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
onClick={() => setActiveSection(entry.id)}
|
||||
aria-label={entry.label}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider transition-colors ${
|
||||
activeSection === entry.id
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{entry.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const isExpanded = expandedSections[entry.label] ?? true;
|
||||
const Icon = entry.icon;
|
||||
|
||||
return (
|
||||
<div key={section.label}>
|
||||
<div key={entry.label}>
|
||||
<button
|
||||
onClick={() => toggleSection(section.label)}
|
||||
onClick={() => toggleSection(entry.label)}
|
||||
className="flex items-center justify-between w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{section.label}</span>
|
||||
<span>{entry.label}</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
@ -799,7 +1177,7 @@ export function KubernetesPage() {
|
||||
|
||||
{isExpanded && (
|
||||
<div className="pb-1">
|
||||
{section.items.map((item) => (
|
||||
{entry.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveSection(item.id)}
|
||||
@ -866,7 +1244,7 @@ export function KubernetesPage() {
|
||||
<p className="text-sm text-muted-foreground">No cluster connected.</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground pt-2 border-t">
|
||||
Navigate to <strong>Cluster → Events</strong> to view live cluster events.
|
||||
Navigate to <strong>Events</strong> to view live cluster events.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user