tftsr-devops_investigation/src/components/Kubernetes/HelmReleaseList.tsx
Shaun Arman aee739c078 feat(kube): nav restructure, action menus, new resource lists, advanced components
Navigation:
- Restructure to match requested layout: Cluster, Nodes, Workloads, Config,
  Network, Storage, Namespaces, Events, Helm, Access Control, Custom Resources
- Workloads: add Overview dashboard and Replication Controllers
- Config: add PDB, PriorityClass, RuntimeClass, Lease, Mutating/Validating Webhooks
- Network: add Endpoints, EndpointSlices, IngressClasses; move Port Forwarding here
- Helm and Custom Resources sections wired through

New shared components:
- ResourceActionMenu: state-aware MoreHorizontal dropdown
- ConfirmDeleteDialog: confirmation guard for all destructive operations
- ScaleModal: replica count dialog (Deployments, StatefulSets, ReplicaSets, RCs)
- LogsModal: container log viewer replacing PodList inline dialog
- ShellExecModal: kubectl exec -it with container and shell selector
- AttachModal: kubectl attach -it with container selector

New resource list components (12):
ReplicationControllerList, PodDisruptionBudgetList, PriorityClassList,
RuntimeClassList, LeaseList, MutatingWebhookList, ValidatingWebhookList,
EndpointList, EndpointSliceList, IngressClassList, NamespaceList,
WorkloadOverview

New advanced components (5):
LogStreamPanel (Tauri-event streaming, follow/search/download),
HelmChartList, HelmReleaseList, CrdList, CustomResourceList

Updated 24 existing list components with context-appropriate action menus:
- Pods: Logs, Shell, Attach, Edit, Delete, Force Delete (state-aware)
- Deployments: Scale, Restart, Rollback, Edit, Delete
- StatefulSets/ReplicaSets: Scale, Restart/none, Edit, Delete
- DaemonSets: Restart, Edit, Delete
- Jobs: Edit, Delete
- CronJobs: Suspend/Resume (state-aware), Trigger, Edit, Delete
- Services/Ingresses/ConfigMaps/Secrets/HPAs/PVCs/PVs/StorageClasses/
  NetworkPolicies/ResourceQuotas/LimitRanges: Edit, Delete
- Nodes: Cordon/Uncordon (state-aware), Drain, Edit
- All RBAC resources: Edit, Delete

Co-Authored-By: TFTSR Engineering <noreply@tftsr.com>
2026-06-08 20:38:05 -05:00

263 lines
9.3 KiB
TypeScript

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