feat(kube): Kubernetes UI — FreeLens v5 feature parity #85

Merged
sarman merged 6 commits from feat/kube-ui-feature-parity into master 2026-06-09 02:05:06 +00:00
49 changed files with 6021 additions and 1194 deletions
Showing only changes of commit aee739c078 - Show all commits

View 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>
);
}

View File

@ -1,41 +1,131 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { ClusterRoleBindingInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface ClusterRoleBindingListProps {
clusterRoleBindings: ClusterRoleBindingInfo[];
_clusterId: string;
clusterId?: string;
_clusterId?: string;
onRefresh?: () => void;
}
export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: ClusterRoleBindingListProps) {
type ActiveModal =
| { type: "edit"; crb: ClusterRoleBindingInfo; yaml: string }
| { type: "delete"; crb: ClusterRoleBindingInfo }
| null;
export function ClusterRoleBindingList({
clusterRoleBindings,
clusterId,
_clusterId,
onRefresh,
}: ClusterRoleBindingListProps) {
const cid = clusterId ?? _clusterId ?? "";
const [activeModal, setActiveModal] = useState<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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Cluster Role</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{clusterRoleBindings.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
No cluster role bindings found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Cluster Role</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
clusterRoleBindings.map((crb) => (
<TableRow key={crb.name}>
<TableCell className="font-medium">{crb.name}</TableCell>
<TableCell>{crb.cluster_role}</TableCell>
<TableCell className="text-muted-foreground">{crb.age}</TableCell>
</TableHeader>
<TableBody>
{clusterRoleBindings.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No cluster role bindings found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
clusterRoleBindings.map((crb) => (
<TableRow key={crb.name}>
<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}
/>
)}
</>
);
}

View File

@ -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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{clusterRoles.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={2} className="text-center text-muted-foreground">
No cluster roles found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
clusterRoles.map((clusterRole) => (
<TableRow key={clusterRole.name}>
<TableCell className="font-medium">{clusterRole.name}</TableCell>
<TableCell className="text-muted-foreground">{clusterRole.age}</TableCell>
</TableHeader>
<TableBody>
{clusterRoles.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
No cluster roles found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
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}
/>
)}
</>
);
}

View File

@ -1,57 +1,127 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Button } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { ConfigMapInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface ConfigMapListProps {
configmaps: ConfigMapInfo[];
clusterId: string;
namespace: string;
onRefresh?: () => void;
}
export function ConfigMapList({ configmaps }: ConfigMapListProps) {
type ActiveModal =
| { type: "edit"; cm: ConfigMapInfo; yaml: string }
| { type: "delete"; cm: ConfigMapInfo }
| null;
export function ConfigMapList({ configmaps, clusterId, namespace, onRefresh }: ConfigMapListProps) {
const [activeModal, setActiveModal] = useState<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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Data Keys</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{configmaps.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No configmaps found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Data Keys</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</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>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => {}}
className="text-primary hover:text-primary hover:bg-primary/10"
>
View/Edit
</Button>
</TableHeader>
<TableBody>
{configmaps.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No configmaps found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
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">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(cm),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", cm }),
},
]}
/>
</TableCell>
</TableRow>
))
)}
</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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -1,54 +1,206 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { PauseCircle, PlayCircle, Play, Pencil, Trash2 } from "lucide-react";
import type { CronJobInfo } from "@/lib/tauriCommands";
import {
suspendCronjobCmd,
resumeCronjobCmd,
triggerCronjobCmd,
deleteResourceCmd,
getResourceYamlCmd,
} from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface CronJobListProps {
cronJobs: CronJobInfo[];
_clusterId: string;
_namespace: string;
clusterId?: string;
_clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
}
export function CronJobList({ cronJobs, _clusterId, _namespace }: CronJobListProps) {
type ActiveModal =
| { type: "edit"; cj: CronJobInfo; yaml: string }
| { type: "delete"; cj: CronJobInfo }
| null;
export function CronJobList({
cronJobs,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: CronJobListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>Active</TableHead>
<TableHead>Last Schedule</TableHead>
<TableHead>Age</TableHead>
<TableHead>Labels</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cronJobs.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No cron jobs found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>Active</TableHead>
<TableHead>Last Schedule</TableHead>
<TableHead>Age</TableHead>
<TableHead>Labels</TableHead>
<TableHead className="text-right">Actions</TableHead>
</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>
<TableCell>
{Object.entries(cronJob.labels)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</TableHeader>
<TableBody>
{cronJobs.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
No cron jobs found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
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(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}
/>
)}
</>
);
}

View 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>
);
}

View File

@ -1,50 +1,169 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { RotateCcw, Pencil, Trash2 } from "lucide-react";
import type { DaemonSetInfo } from "@/lib/tauriCommands";
import {
restartDaemonsetCmd,
deleteResourceCmd,
getResourceYamlCmd,
} from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface DaemonSetListProps {
daemonsets: DaemonSetInfo[];
clusterId: string;
namespace: string;
onRefresh?: () => void;
}
export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _namespace }: DaemonSetListProps) {
type ActiveModal =
| { type: "restart"; ds: DaemonSetInfo }
| { type: "edit"; ds: DaemonSetInfo; yaml: string }
| { type: "delete"; ds: DaemonSetInfo }
| null;
export function DaemonSetList({ daemonsets, clusterId, namespace, onRefresh }: DaemonSetListProps) {
const [activeModal, setActiveModal] = useState<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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Desired</TableHead>
<TableHead>Current</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Up-to-date</TableHead>
<TableHead>Available</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{daemonsets.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No daemonsets found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Desired</TableHead>
<TableHead>Current</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Up-to-date</TableHead>
<TableHead>Available</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
daemonsets.map((ds) => (
<TableRow key={ds.name}>
<TableCell className="font-medium">{ds.name}</TableCell>
<TableCell>{ds.desired}</TableCell>
<TableCell>{ds.current}</TableCell>
<TableCell>{ds.ready}</TableCell>
<TableCell>{ds.up_to_date}</TableCell>
<TableCell>{ds.available}</TableCell>
<TableCell className="text-muted-foreground">{ds.age}</TableCell>
</TableHeader>
<TableBody>
{daemonsets.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
No daemonsets found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
daemonsets.map((ds) => (
<TableRow key={ds.name}>
<TableCell className="font-medium">{ds.name}</TableCell>
<TableCell>{ds.desired}</TableCell>
<TableCell>{ds.current}</TableCell>
<TableCell>{ds.ready}</TableCell>
<TableCell>{ds.up_to_date}</TableCell>
<TableCell>{ds.available}</TableCell>
<TableCell className="text-muted-foreground">{ds.age}</TableCell>
<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}
/>
)}
</>
);
}

View File

@ -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"
/>
{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>
{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?.();
})
}
/>
)}
{/* Restart Dialog */}
<Dialog open={!!restartingDeployment} onOpenChange={() => setRestartingDeployment(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Restart Deployment</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
This will trigger a rolling restart of the deployment.
</p>
{restartError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{restartError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRestartingDeployment(null)}>
Cancel
</Button>
<Button onClick={handleRestartSubmit} disabled={isRestarting}>
{isRestarting ? "Restarting..." : "Restart"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -1,50 +1,144 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { HorizontalPodAutoscalerInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface HPAListProps {
hpas: HorizontalPodAutoscalerInfo[];
_clusterId: string;
_namespace: string;
clusterId?: string;
_clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
}
export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) {
type ActiveModal =
| { type: "edit"; hpa: HorizontalPodAutoscalerInfo; yaml: string }
| { type: "delete"; hpa: HorizontalPodAutoscalerInfo }
| null;
export function HPAList({
hpas,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: HPAListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Min Replicas</TableHead>
<TableHead>Max Replicas</TableHead>
<TableHead>Current Replicas</TableHead>
<TableHead>Desired Replicas</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hpas.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No HPAs found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Min Replicas</TableHead>
<TableHead>Max Replicas</TableHead>
<TableHead>Current Replicas</TableHead>
<TableHead>Desired Replicas</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
hpas.map((hpa) => (
<TableRow key={`${hpa.name}-${hpa.namespace}`}>
<TableCell className="font-medium">{hpa.name}</TableCell>
<TableCell>{hpa.namespace}</TableCell>
<TableCell>{hpa.min_replicas}</TableCell>
<TableCell>{hpa.max_replicas}</TableCell>
<TableCell>{hpa.current_replicas}</TableCell>
<TableCell>{hpa.desired_replicas}</TableCell>
<TableCell className="text-muted-foreground">{hpa.age}</TableCell>
</TableHeader>
<TableBody>
{hpas.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
No HPAs found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
hpas.map((hpa) => (
<TableRow key={`${hpa.name}-${hpa.namespace}`}>
<TableCell className="font-medium">{hpa.name}</TableCell>
<TableCell>{hpa.namespace}</TableCell>
<TableCell>{hpa.min_replicas}</TableCell>
<TableCell>{hpa.max_replicas}</TableCell>
<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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -1,48 +1,142 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { IngressInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface IngressListProps {
ingresses: IngressInfo[];
_clusterId: string;
_namespace: string;
clusterId?: string;
_clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
}
export function IngressList({ ingresses, _clusterId, _namespace }: IngressListProps) {
type ActiveModal =
| { type: "edit"; ingress: IngressInfo; yaml: string }
| { type: "delete"; ingress: IngressInfo }
| null;
export function IngressList({
ingresses,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: IngressListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Class</TableHead>
<TableHead>Host</TableHead>
<TableHead>Addresses</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ingresses.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No ingresses found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Class</TableHead>
<TableHead>Host</TableHead>
<TableHead>Addresses</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
ingresses.map((ingress) => (
<TableRow key={`${ingress.name}-${ingress.namespace}`}>
<TableCell className="font-medium">{ingress.name}</TableCell>
<TableCell>{ingress.namespace}</TableCell>
<TableCell>{ingress.class || "-"}</TableCell>
<TableCell>{ingress.host}</TableCell>
<TableCell>{ingress.addresses.join(", ")}</TableCell>
<TableCell className="text-muted-foreground">{ingress.age}</TableCell>
</TableHeader>
<TableBody>
{ingresses.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No ingresses found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
ingresses.map((ingress) => (
<TableRow key={`${ingress.name}-${ingress.namespace}`}>
<TableCell className="font-medium">{ingress.name}</TableCell>
<TableCell>{ingress.namespace}</TableCell>
<TableCell>{ingress.class || "-"}</TableCell>
<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}
/>
)}
</>
);
}

View File

@ -1,52 +1,146 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { JobInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface JobListProps {
jobs: JobInfo[];
_clusterId: string;
_namespace: string;
clusterId?: string;
_clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
}
export function JobList({ jobs, _clusterId, _namespace }: JobListProps) {
type ActiveModal =
| { type: "edit"; job: JobInfo; yaml: string }
| { type: "delete"; job: JobInfo }
| null;
export function JobList({
jobs,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: JobListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Completions</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Age</TableHead>
<TableHead>Labels</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No jobs found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Completions</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Age</TableHead>
<TableHead>Labels</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
jobs.map((job) => (
<TableRow key={`${job.name}-${job.namespace}`}>
<TableCell className="font-medium">{job.name}</TableCell>
<TableCell>{job.namespace}</TableCell>
<TableCell>{job.completions}</TableCell>
<TableCell>{job.duration}</TableCell>
<TableCell className="text-muted-foreground">{job.age}</TableCell>
<TableCell>
{Object.entries(job.labels)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</TableHeader>
<TableBody>
{jobs.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No jobs found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
jobs.map((job) => (
<TableRow key={`${job.name}-${job.namespace}`}>
<TableCell className="font-medium">{job.name}</TableCell>
<TableCell>{job.namespace}</TableCell>
<TableCell>{job.completions}</TableCell>
<TableCell>{job.duration}</TableCell>
<TableCell className="text-muted-foreground">{job.age}</TableCell>
<TableCell>
{Object.entries(job.labels)
.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}
/>
)}
</>
);
}

View 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>
);
}

View File

@ -1,44 +1,127 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { LimitRangeInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface LimitRangeListProps {
limitranges: LimitRangeInfo[];
clusterId: string;
namespace: string;
onRefresh?: () => void;
}
export function LimitRangeList({ limitranges }: LimitRangeListProps) {
type ActiveModal =
| { type: "edit"; lr: LimitRangeInfo; yaml: string }
| { type: "delete"; lr: LimitRangeInfo }
| null;
export function LimitRangeList({ limitranges, clusterId, namespace, onRefresh }: LimitRangeListProps) {
const [activeModal, setActiveModal] = useState<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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Limits</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{limitranges.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No limit ranges found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Limits</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
limitranges.map((lr) => (
<TableRow key={`${lr.name}-${lr.namespace}`}>
<TableCell className="font-medium">{lr.name}</TableCell>
<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>
</TableHeader>
<TableBody>
{limitranges.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No limit ranges found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
limitranges.map((lr) => (
<TableRow key={`${lr.name}-${lr.namespace}`}>
<TableCell className="font-medium">{lr.name}</TableCell>
<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}
/>
)}
</>
);
}

View 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)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -1,46 +1,129 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { NetworkPolicyInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface NetworkPolicyListProps {
networkpolicies: NetworkPolicyInfo[];
clusterId: string;
namespace: string;
onRefresh?: () => void;
}
export function NetworkPolicyList({ networkpolicies }: NetworkPolicyListProps) {
type ActiveModal =
| { type: "edit"; np: NetworkPolicyInfo; yaml: string }
| { type: "delete"; np: NetworkPolicyInfo }
| null;
export function NetworkPolicyList({ networkpolicies, clusterId, namespace, onRefresh }: NetworkPolicyListProps) {
const [activeModal, setActiveModal] = useState<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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Pod Selector</TableHead>
<TableHead>Policy Types</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{networkpolicies.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No network policies found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Pod Selector</TableHead>
<TableHead>Policy Types</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
networkpolicies.map((np) => (
<TableRow key={`${np.name}-${np.namespace}`}>
<TableCell className="font-medium">{np.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{np.namespace}</TableCell>
<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>
</TableHeader>
<TableBody>
{networkpolicies.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No network policies found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
networkpolicies.map((np) => (
<TableRow key={`${np.name}-${np.namespace}`}>
<TableCell className="font-medium">{np.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{np.namespace}</TableCell>
<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}
/>
)}
</>
);
}

View File

@ -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;
setIsCordoning(true);
setError(null);
const isSchedulingDisabled = (node: NodeInfo) =>
node.status.toLowerCase().includes("schedulingdisabled") ||
node.roles.toLowerCase().includes("schedulingdisabled");
const handleCordon = async (node: NodeInfo) => {
setActionError(null);
try {
await invoke<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>
{activeModal?.type === "drain" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="Node"
resourceName={activeModal.node.name}
isLoading={isActing}
onConfirm={handleDrain}
variant="force-delete"
/>
)}
<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>
)}
<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?.(); }}
/>
)}
</>
);

View File

@ -1,50 +1,144 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { PersistentVolumeClaimInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface PVCListProps {
pvcs: PersistentVolumeClaimInfo[];
_clusterId: string;
_namespace: string;
clusterId?: string;
_clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
}
export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) {
type ActiveModal =
| { type: "edit"; pvc: PersistentVolumeClaimInfo; yaml: string }
| { type: "delete"; pvc: PersistentVolumeClaimInfo }
| null;
export function PVCList({
pvcs,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: PVCListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Status</TableHead>
<TableHead>Volume</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Access Modes</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pvcs.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No PVCs found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Status</TableHead>
<TableHead>Volume</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Access Modes</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
pvcs.map((pvc) => (
<TableRow key={`${pvc.name}-${pvc.namespace}`}>
<TableCell className="font-medium">{pvc.name}</TableCell>
<TableCell>{pvc.namespace}</TableCell>
<TableCell>{pvc.status}</TableCell>
<TableCell>{pvc.volume}</TableCell>
<TableCell>{pvc.capacity}</TableCell>
<TableCell>{pvc.access_modes.join(", ")}</TableCell>
<TableCell className="text-muted-foreground">{pvc.age}</TableCell>
</TableHeader>
<TableBody>
{pvcs.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
No PVCs found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
pvcs.map((pvc) => (
<TableRow key={`${pvc.name}-${pvc.namespace}`}>
<TableCell className="font-medium">{pvc.name}</TableCell>
<TableCell>{pvc.namespace}</TableCell>
<TableCell>{pvc.status}</TableCell>
<TableCell>{pvc.volume}</TableCell>
<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}
/>
)}
</>
);
}

View File

@ -1,49 +1,134 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { PersistentVolumeInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface PVListProps {
pvs: PersistentVolumeInfo[];
_clusterId: string;
clusterId?: string;
_clusterId?: string;
onRefresh?: () => void;
}
export function PVList({ pvs, _clusterId }: PVListProps) {
type ActiveModal =
| { type: "edit"; pv: PersistentVolumeInfo; yaml: string }
| { type: "delete"; pv: PersistentVolumeInfo }
| null;
export function PVList({ pvs, clusterId, _clusterId, onRefresh }: PVListProps) {
const cid = clusterId ?? _clusterId ?? "";
const [activeModal, setActiveModal] = useState<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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Access Modes</TableHead>
<TableHead>Reclaim Policy</TableHead>
<TableHead>Storage Class</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pvs.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No PVs found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Access Modes</TableHead>
<TableHead>Reclaim Policy</TableHead>
<TableHead>Storage Class</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
pvs.map((pv) => (
<TableRow key={pv.name}>
<TableCell className="font-medium">{pv.name}</TableCell>
<TableCell>{pv.status}</TableCell>
<TableCell>{pv.capacity}</TableCell>
<TableCell>{pv.access_modes.join(", ")}</TableCell>
<TableCell>{pv.reclaim_policy}</TableCell>
<TableCell>{pv.storage_class}</TableCell>
<TableCell className="text-muted-foreground">{pv.age}</TableCell>
</TableHeader>
<TableBody>
{pvs.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
No PVs found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
pvs.map((pv) => (
<TableRow key={pv.name}>
<TableCell className="font-medium">{pv.name}</TableCell>
<TableCell>{pv.status}</TableCell>
<TableCell>{pv.capacity}</TableCell>
<TableCell>{pv.access_modes.join(", ")}</TableCell>
<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}
/>
)}
</>
);
}

View 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>
);
}

View File

@ -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."
/>
</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>
<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 }),
},
]}
/>
</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)}
/>
)}
</>
);
}

View 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>
);
}

View File

@ -1,52 +1,173 @@
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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Replicas</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Age</TableHead>
<TableHead>Labels</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{replicaSets.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No replica sets found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Replicas</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Age</TableHead>
<TableHead>Labels</TableHead>
<TableHead className="text-right">Actions</TableHead>
</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>
<TableCell>
{Object.entries(replicaSet.labels)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}
</TableHeader>
<TableBody>
{replicaSets.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No replica sets found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
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(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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -1,50 +1,133 @@
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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>CPU Req</TableHead>
<TableHead>Mem Req</TableHead>
<TableHead>CPU Limit</TableHead>
<TableHead>Mem Limit</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resourcequotas.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No resource quotas found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>CPU Req</TableHead>
<TableHead>Mem Req</TableHead>
<TableHead>CPU Limit</TableHead>
<TableHead>Mem Limit</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
resourcequotas.map((rq) => (
<TableRow key={`${rq.name}-${rq.namespace}`}>
<TableCell className="font-medium">{rq.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{rq.namespace}</TableCell>
<TableCell className="text-sm font-mono">{rq.request_cpu || "—"}</TableCell>
<TableCell className="text-sm font-mono">{rq.request_memory || "—"}</TableCell>
<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>
</TableHeader>
<TableBody>
{resourcequotas.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
No resource quotas found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
resourcequotas.map((rq) => (
<TableRow key={`${rq.name}-${rq.namespace}`}>
<TableCell className="font-medium">{rq.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{rq.namespace}</TableCell>
<TableCell className="text-sm font-mono">{rq.request_cpu || "—"}</TableCell>
<TableCell className="text-sm font-mono">{rq.request_memory || "—"}</TableCell>
<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}
/>
)}
</>
);
}

View File

@ -1,44 +1,138 @@
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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Role</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roleBindings.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No role bindings found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Role</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
roleBindings.map((rb) => (
<TableRow key={`${rb.name}-${rb.namespace}`}>
<TableCell className="font-medium">{rb.name}</TableCell>
<TableCell>{rb.namespace}</TableCell>
<TableCell>{rb.role}</TableCell>
<TableCell className="text-muted-foreground">{rb.age}</TableCell>
</TableHeader>
<TableBody>
{roleBindings.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No role bindings found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
roleBindings.map((rb) => (
<TableRow key={`${rb.name}-${rb.namespace}`}>
<TableCell className="font-medium">{rb.name}</TableCell>
<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}
/>
)}
</>
);
}

View File

@ -1,42 +1,136 @@
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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
No roles found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
roles.map((role) => (
<TableRow key={`${role.name}-${role.namespace}`}>
<TableCell className="font-medium">{role.name}</TableCell>
<TableCell>{role.namespace}</TableCell>
<TableCell className="text-muted-foreground">{role.age}</TableCell>
</TableHeader>
<TableBody>
{roles.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No roles found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
roles.map((role) => (
<TableRow key={`${role.name}-${role.namespace}`}>
<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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -1,50 +1,140 @@
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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Type</TableHead>
<TableHead>Data Keys</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{secrets.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No secrets found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Type</TableHead>
<TableHead>Data Keys</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
secrets.map((secret) => (
<TableRow key={`${secret.name}-${secret.namespace}`}>
<TableCell className="font-medium">{secret.name}</TableCell>
<TableCell>{secret.namespace}</TableCell>
<TableCell>{secret.type}</TableCell>
<TableCell>{secret.data_keys}</TableCell>
<TableCell className="text-muted-foreground">{secret.age}</TableCell>
<TableCell className="text-right">
<span className="text-sm">View/Edit</span>
</TableHeader>
<TableBody>
{secrets.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No secrets found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
secrets.map((secret) => (
<TableRow key={`${secret.name}-${secret.namespace}`}>
<TableCell className="font-medium">{secret.name}</TableCell>
<TableCell>{secret.namespace}</TableCell>
<TableCell>{secret.type}</TableCell>
<TableCell>{secret.data_keys}</TableCell>
<TableCell className="text-muted-foreground">{secret.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(secret),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", secret }),
},
]}
/>
</TableCell>
</TableRow>
))
)}
</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}
/>
)}
</>
);
}

View File

@ -1,44 +1,138 @@
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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Secrets</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{serviceAccounts.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No service accounts found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Secrets</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
serviceAccounts.map((sa) => (
<TableRow key={`${sa.name}-${sa.namespace}`}>
<TableCell className="font-medium">{sa.name}</TableCell>
<TableCell>{sa.namespace}</TableCell>
<TableCell>{sa.secrets}</TableCell>
<TableCell className="text-muted-foreground">{sa.age}</TableCell>
</TableHeader>
<TableBody>
{serviceAccounts.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No service accounts found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
serviceAccounts.map((sa) => (
<TableRow key={`${sa.name}-${sa.namespace}`}>
<TableCell className="font-medium">{sa.name}</TableCell>
<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}
/>
)}
</>
);
}

View File

@ -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,56 +40,124 @@ 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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Cluster IP</TableHead>
<TableHead>External IP</TableHead>
<TableHead>Ports</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No services found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Cluster IP</TableHead>
<TableHead>External IP</TableHead>
<TableHead>Ports</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
services.map((service) => (
<TableRow key={`${service.name}-${service.namespace}`}>
<TableCell className="font-medium">{service.name}</TableCell>
<TableCell>
<Badge className={`${getServiceTypeColor(service.type)} text-white`}>
{service.type}
</Badge>
</TableHeader>
<TableBody>
{services.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No services found
</TableCell>
<TableCell className="font-mono text-sm">{service.cluster_ip}</TableCell>
<TableCell className="font-mono text-sm">
{service.external_ip || "N/A"}
</TableCell>
<TableCell>
<div className="space-y-1">
{service.ports.map((port) => (
<div key={`${port.port}-${port.protocol}`} className="text-sm">
{port.name ? `${port.name}: ` : ""}
{port.port}/{port.protocol}
{port.target_port && `${port.target_port}`}
</div>
))}
</div>
</TableCell>
<TableCell className="text-muted-foreground">{service.age}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
services.map((service) => (
<TableRow key={`${service.name}-${service.namespace}`}>
<TableCell className="font-medium">{service.name}</TableCell>
<TableCell>
<Badge className={`${getServiceTypeColor(service.type)} text-white`}>
{service.type}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm">{service.cluster_ip}</TableCell>
<TableCell className="font-mono text-sm">
{service.external_ip || "N/A"}
</TableCell>
<TableCell>
<div className="space-y-1">
{service.ports.map((port) => (
<div key={`${port.port}-${port.protocol}`} className="text-sm">
{port.name ? `${port.name}: ` : ""}
{port.port}/{port.protocol}
{port.target_port && `${port.target_port}`}
</div>
))}
</div>
</TableCell>
<TableCell className="text-muted-foreground">{service.age}</TableCell>
<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}
/>
)}
</>
);
}

View 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>
);
}

View File

@ -1,44 +1,187 @@
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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Replicas</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{statefulsets.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No statefulsets found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Replicas</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
statefulsets.map((ss) => (
<TableRow key={ss.name}>
<TableCell className="font-medium">{ss.name}</TableCell>
<TableCell>{ss.ready}</TableCell>
<TableCell>{ss.replicas}</TableCell>
<TableCell className="text-muted-foreground">{ss.age}</TableCell>
</TableHeader>
<TableBody>
{statefulsets.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No statefulsets found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
statefulsets.map((ss) => (
<TableRow key={ss.name}>
<TableCell className="font-medium">{ss.name}</TableCell>
<TableCell>{ss.ready}</TableCell>
<TableCell>{ss.replicas}</TableCell>
<TableCell className="text-muted-foreground">{ss.age}</TableCell>
<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}
/>
)}
</>
);
}

View File

@ -1,48 +1,131 @@
import React from "react";
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { 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 (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Provisioner</TableHead>
<TableHead>Reclaim Policy</TableHead>
<TableHead>Volume Binding Mode</TableHead>
<TableHead>Expand</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{storageclasses.length === 0 ? (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No storage classes found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Provisioner</TableHead>
<TableHead>Reclaim Policy</TableHead>
<TableHead>Volume Binding Mode</TableHead>
<TableHead>Expand</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
storageclasses.map((sc) => (
<TableRow key={sc.name}>
<TableCell className="font-medium">{sc.name}</TableCell>
<TableCell className="text-sm font-mono">{sc.provisioner}</TableCell>
<TableCell className="text-sm">{sc.reclaim_policy}</TableCell>
<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>
</TableHeader>
<TableBody>
{storageclasses.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No storage classes found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
storageclasses.map((sc) => (
<TableRow key={sc.name}>
<TableCell className="font-medium">{sc.name}</TableCell>
<TableCell className="text-sm font-mono">{sc.provisioner}</TableCell>
<TableCell className="text-sm">{sc.reclaim_policy}</TableCell>
<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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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";

View File

@ -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>