kubeops 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -0
- package/electron/main.js +429 -0
- package/electron/preload.js +13 -0
- package/electron-builder.yml +60 -0
- package/next.config.ts +8 -0
- package/package.json +98 -0
- package/postcss.config.mjs +7 -0
- package/resources/icon.icns +0 -0
- package/resources/icon.ico +0 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +86 -0
- package/scripts/build-server.mjs +20 -0
- package/scripts/generate-icons.mjs +61 -0
- package/server.ts +58 -0
- package/src/app/api/clusters/[clusterId]/health/route.ts +27 -0
- package/src/app/api/clusters/[clusterId]/metrics/route.ts +196 -0
- package/src/app/api/clusters/[clusterId]/namespaces/route.ts +48 -0
- package/src/app/api/clusters/[clusterId]/nodes/[nodeName]/route.ts +21 -0
- package/src/app/api/clusters/[clusterId]/nodes/route.ts +31 -0
- package/src/app/api/clusters/[clusterId]/resources/[namespace]/[...resourcePath]/route.ts +204 -0
- package/src/app/api/clusters/route.ts +48 -0
- package/src/app/api/kubeconfig/route.ts +25 -0
- package/src/app/api/port-forward/route.ts +143 -0
- package/src/app/api/tsh/login/route.ts +50 -0
- package/src/app/clusters/[clusterId]/app-map/page.tsx +42 -0
- package/src/app/clusters/[clusterId]/clusterrolebindings/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/clusterrolebindings/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/clusterroles/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/clusterroles/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/layout.tsx +9 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/configmaps/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/configmaps/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/cronjobs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/cronjobs/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/daemonsets/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/daemonsets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/deployments/[name]/page.tsx +457 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/deployments/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/endpoints/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/endpoints/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/events/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/ingresses/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/ingresses/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/jobs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/jobs/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/networkpolicies/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/networkpolicies/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/exec/page.tsx +173 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/logs/page.tsx +137 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/page.tsx +448 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pvcs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pvcs/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/replicasets/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/replicasets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/rolebindings/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/rolebindings/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/roles/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/roles/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/secrets/[name]/page.tsx +168 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/secrets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/serviceaccounts/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/serviceaccounts/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/services/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/services/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/statefulsets/[name]/page.tsx +302 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/statefulsets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/nodes/[nodeName]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/nodes/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/page.tsx +635 -0
- package/src/app/clusters/[clusterId]/port-forwarding/page.tsx +145 -0
- package/src/app/clusters/[clusterId]/pvs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/pvs/page.tsx +5 -0
- package/src/app/clusters/page.tsx +166 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +167 -0
- package/src/app/layout.tsx +48 -0
- package/src/app/page.tsx +5 -0
- package/src/components/clusters/cluster-selector.tsx +64 -0
- package/src/components/layout/app-shell.tsx +26 -0
- package/src/components/layout/breadcrumbs.tsx +97 -0
- package/src/components/layout/command-palette.tsx +112 -0
- package/src/components/layout/header.tsx +184 -0
- package/src/components/layout/sidebar.tsx +84 -0
- package/src/components/layout/theme-toggle.tsx +21 -0
- package/src/components/namespaces/namespace-selector.tsx +165 -0
- package/src/components/panel/bottom-panel.tsx +127 -0
- package/src/components/panel/logs-tab.tsx +109 -0
- package/src/components/panel/terminal-tab.tsx +180 -0
- package/src/components/pods/pod-watch-button.tsx +44 -0
- package/src/components/resources/resource-columns.tsx +320 -0
- package/src/components/resources/resource-detail-page.tsx +191 -0
- package/src/components/resources/resource-list-page.tsx +78 -0
- package/src/components/resources/scale-dialog.tsx +107 -0
- package/src/components/settings/settings-dialog.tsx +103 -0
- package/src/components/shared/age-display.tsx +27 -0
- package/src/components/shared/confirm-dialog.tsx +52 -0
- package/src/components/shared/data-table.tsx +149 -0
- package/src/components/shared/env-value-resolver.tsx +570 -0
- package/src/components/shared/error-display.tsx +109 -0
- package/src/components/shared/loading-skeleton.tsx +25 -0
- package/src/components/shared/metrics-charts-impl.tsx +434 -0
- package/src/components/shared/metrics-charts.tsx +24 -0
- package/src/components/shared/port-forward-btn.tsx +60 -0
- package/src/components/shared/resource-info-drawer.tsx +542 -0
- package/src/components/shared/resource-node.tsx +157 -0
- package/src/components/shared/resource-tree-impl.tsx +228 -0
- package/src/components/shared/resource-tree.tsx +20 -0
- package/src/components/shared/status-badge.tsx +35 -0
- package/src/components/shared/yaml-editor.tsx +438 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/command.tsx +184 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/popover.tsx +89 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +40 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/hooks/use-age-tick.ts +40 -0
- package/src/hooks/use-auto-update.ts +100 -0
- package/src/hooks/use-clusters.ts +32 -0
- package/src/hooks/use-namespaces.ts +17 -0
- package/src/hooks/use-pod-watcher.ts +79 -0
- package/src/hooks/use-port-forwards.ts +18 -0
- package/src/hooks/use-resource-detail.ts +28 -0
- package/src/hooks/use-resource-list.ts +26 -0
- package/src/hooks/use-resource-tree.ts +440 -0
- package/src/lib/api-client.ts +31 -0
- package/src/lib/constants.ts +126 -0
- package/src/lib/k8s/client-factory.ts +57 -0
- package/src/lib/k8s/kubeconfig-manager.ts +43 -0
- package/src/lib/k8s/resource-api.ts +223 -0
- package/src/lib/k8s/types.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/providers/swr-provider.tsx +20 -0
- package/src/providers/theme-provider.tsx +17 -0
- package/src/stores/cluster-store.ts +32 -0
- package/src/stores/namespace-store.ts +27 -0
- package/src/stores/panel-store.ts +61 -0
- package/src/stores/pod-watcher-store.ts +69 -0
- package/src/stores/settings-store.ts +24 -0
- package/src/stores/sidebar-store.ts +22 -0
- package/src/types/cluster.ts +19 -0
- package/src/types/css.d.ts +6 -0
- package/src/types/electron.d.ts +25 -0
- package/src/types/navigation.ts +4 -0
- package/src/types/resource.ts +27 -0
- package/tsconfig.json +34 -0
- package/ws/exec-handler.ts +112 -0
- package/ws/index.ts +2 -0
- package/ws/logs-handler.ts +70 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useParams, useRouter } from 'next/navigation';
|
|
4
|
+
import { useResourceDetail } from '@/hooks/use-resource-detail';
|
|
5
|
+
import { LoadingSkeleton } from '@/components/shared/loading-skeleton';
|
|
6
|
+
import { ErrorDisplay } from '@/components/shared/error-display';
|
|
7
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
8
|
+
import { Badge } from '@/components/ui/badge';
|
|
9
|
+
import { Button } from '@/components/ui/button';
|
|
10
|
+
import { AgeDisplay } from '@/components/shared/age-display';
|
|
11
|
+
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
|
12
|
+
import { ArrowLeft, Trash2, Eye, EyeOff, Copy } from 'lucide-react';
|
|
13
|
+
import { apiClient } from '@/lib/api-client';
|
|
14
|
+
import { toast } from 'sonner';
|
|
15
|
+
import { useState } from 'react';
|
|
16
|
+
import * as yaml from 'js-yaml';
|
|
17
|
+
|
|
18
|
+
export default function SecretDetailPage() {
|
|
19
|
+
const params = useParams();
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
const clusterId = params.clusterId as string;
|
|
22
|
+
const namespace = params.namespace as string;
|
|
23
|
+
const name = params.name as string;
|
|
24
|
+
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
|
|
25
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
26
|
+
const [deleting, setDeleting] = useState(false);
|
|
27
|
+
|
|
28
|
+
const { data: secret, error, isLoading, mutate } = useResourceDetail({
|
|
29
|
+
clusterId: decodeURIComponent(clusterId),
|
|
30
|
+
namespace,
|
|
31
|
+
resourceType: 'secrets',
|
|
32
|
+
name,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (isLoading) return <LoadingSkeleton />;
|
|
36
|
+
if (error) return <ErrorDisplay error={error} onRetry={() => mutate()} />;
|
|
37
|
+
if (!secret) return null;
|
|
38
|
+
|
|
39
|
+
const metadata = secret.metadata || {};
|
|
40
|
+
const data = secret.data || {};
|
|
41
|
+
const labels = metadata.labels || {};
|
|
42
|
+
|
|
43
|
+
const toggleReveal = (key: string) => {
|
|
44
|
+
setRevealedKeys((prev) => {
|
|
45
|
+
const next = new Set(prev);
|
|
46
|
+
if (next.has(key)) next.delete(key);
|
|
47
|
+
else next.add(key);
|
|
48
|
+
return next;
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const decodeValue = (value: string) => {
|
|
53
|
+
try { return atob(value); } catch { return value; }
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const copyValue = (value: string) => {
|
|
57
|
+
navigator.clipboard.writeText(decodeValue(value));
|
|
58
|
+
toast.success('Copied to clipboard');
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleDelete = async () => {
|
|
62
|
+
setDeleting(true);
|
|
63
|
+
try {
|
|
64
|
+
await apiClient.delete(`/api/clusters/${clusterId}/resources/${namespace}/secrets/${name}`);
|
|
65
|
+
toast.success(`${name} deleted`);
|
|
66
|
+
router.back();
|
|
67
|
+
} catch (err: any) {
|
|
68
|
+
toast.error(`Delete failed: ${err.message}`);
|
|
69
|
+
} finally {
|
|
70
|
+
setDeleting(false);
|
|
71
|
+
setDeleteOpen(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex flex-col gap-4 p-6">
|
|
77
|
+
<div className="flex items-center gap-3">
|
|
78
|
+
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
79
|
+
<ArrowLeft className="h-4 w-4" />
|
|
80
|
+
</Button>
|
|
81
|
+
<div className="flex-1">
|
|
82
|
+
<h1 className="text-2xl font-bold">{name}</h1>
|
|
83
|
+
<p className="text-sm text-muted-foreground">
|
|
84
|
+
Secret in {namespace} - Type: {secret.type || 'Opaque'}
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
|
|
88
|
+
<Trash2 className="h-4 w-4 mr-1" />
|
|
89
|
+
Delete
|
|
90
|
+
</Button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<Tabs defaultValue="data">
|
|
94
|
+
<TabsList>
|
|
95
|
+
<TabsTrigger value="data">Data ({Object.keys(data).length})</TabsTrigger>
|
|
96
|
+
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
97
|
+
<TabsTrigger value="yaml">YAML</TabsTrigger>
|
|
98
|
+
</TabsList>
|
|
99
|
+
|
|
100
|
+
<TabsContent value="data" className="mt-4">
|
|
101
|
+
<div className="rounded-md border divide-y">
|
|
102
|
+
{Object.entries(data).length > 0 ? (
|
|
103
|
+
Object.entries(data).map(([key, value]) => {
|
|
104
|
+
const isRevealed = revealedKeys.has(key);
|
|
105
|
+
const decoded = decodeValue(value as string);
|
|
106
|
+
return (
|
|
107
|
+
<div key={key} className="p-3 space-y-1">
|
|
108
|
+
<div className="flex items-center justify-between">
|
|
109
|
+
<span className="font-mono text-sm font-medium">{key}</span>
|
|
110
|
+
<div className="flex items-center gap-1">
|
|
111
|
+
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => copyValue(value as string)} title="Copy decoded value">
|
|
112
|
+
<Copy className="h-3.5 w-3.5" />
|
|
113
|
+
</Button>
|
|
114
|
+
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => toggleReveal(key)} title={isRevealed ? 'Hide' : 'Reveal'}>
|
|
115
|
+
{isRevealed ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
116
|
+
</Button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="font-mono text-xs bg-muted rounded px-2 py-1.5 break-all">
|
|
120
|
+
{isRevealed ? decoded : '••••••••••••••••'}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
})
|
|
125
|
+
) : (
|
|
126
|
+
<div className="p-4 text-sm text-muted-foreground text-center">No data</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
</TabsContent>
|
|
130
|
+
|
|
131
|
+
<TabsContent value="overview" className="space-y-4 mt-4">
|
|
132
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
133
|
+
<div className="space-y-3">
|
|
134
|
+
<h3 className="text-sm font-semibold">Metadata</h3>
|
|
135
|
+
<div className="rounded-md border p-3 space-y-2 text-sm">
|
|
136
|
+
<div className="flex justify-between"><span className="text-muted-foreground">Name</span><span className="font-mono">{metadata.name}</span></div>
|
|
137
|
+
<div className="flex justify-between"><span className="text-muted-foreground">Namespace</span><span className="font-mono">{metadata.namespace}</span></div>
|
|
138
|
+
<div className="flex justify-between"><span className="text-muted-foreground">Type</span><span>{secret.type || 'Opaque'}</span></div>
|
|
139
|
+
<div className="flex justify-between"><span className="text-muted-foreground">Keys</span><span>{Object.keys(data).length}</span></div>
|
|
140
|
+
<div className="flex justify-between"><span className="text-muted-foreground">Age</span><AgeDisplay timestamp={metadata.creationTimestamp} /></div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
<div className="space-y-3">
|
|
144
|
+
<h3 className="text-sm font-semibold">Labels</h3>
|
|
145
|
+
<div className="rounded-md border p-3 flex flex-wrap gap-1">
|
|
146
|
+
{Object.entries(labels).length > 0 ? (
|
|
147
|
+
Object.entries(labels).map(([k, v]) => (
|
|
148
|
+
<Badge key={k} variant="secondary" className="text-xs font-mono">{k}={v as string}</Badge>
|
|
149
|
+
))
|
|
150
|
+
) : (
|
|
151
|
+
<span className="text-sm text-muted-foreground">No labels</span>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</TabsContent>
|
|
157
|
+
|
|
158
|
+
<TabsContent value="yaml" className="mt-4">
|
|
159
|
+
<pre className="rounded-md border bg-muted p-4 overflow-auto max-h-[600px] text-xs font-mono whitespace-pre">
|
|
160
|
+
{yaml.dump(secret, { lineWidth: -1 })}
|
|
161
|
+
</pre>
|
|
162
|
+
</TabsContent>
|
|
163
|
+
</Tabs>
|
|
164
|
+
|
|
165
|
+
<ConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} title={`Delete ${name}?`} description={`This will permanently delete the secret "${name}".`} confirmLabel="Delete" variant="destructive" onConfirm={handleDelete} loading={deleting} />
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useParams, useRouter } from 'next/navigation';
|
|
4
|
+
import { useResourceDetail } from '@/hooks/use-resource-detail';
|
|
5
|
+
import { useResourceList } from '@/hooks/use-resource-list';
|
|
6
|
+
import { LoadingSkeleton } from '@/components/shared/loading-skeleton';
|
|
7
|
+
import { ErrorDisplay } from '@/components/shared/error-display';
|
|
8
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
9
|
+
import { Badge } from '@/components/ui/badge';
|
|
10
|
+
import { Button } from '@/components/ui/button';
|
|
11
|
+
import { StatusBadge } from '@/components/shared/status-badge';
|
|
12
|
+
import { AgeDisplay } from '@/components/shared/age-display';
|
|
13
|
+
import { ScaleDialog } from '@/components/resources/scale-dialog';
|
|
14
|
+
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
|
15
|
+
import { YamlEditor } from '@/components/shared/yaml-editor';
|
|
16
|
+
import { PodMetricsCharts } from '@/components/shared/metrics-charts';
|
|
17
|
+
import { ArrowLeft, Trash2, Scaling, Terminal, ScrollText, RotateCcw } from 'lucide-react';
|
|
18
|
+
import { apiClient } from '@/lib/api-client';
|
|
19
|
+
import { toast } from 'sonner';
|
|
20
|
+
import { useState } from 'react';
|
|
21
|
+
import Link from 'next/link';
|
|
22
|
+
import { usePanelStore } from '@/stores/panel-store';
|
|
23
|
+
import { PortForwardBtn } from '@/components/shared/port-forward-btn';
|
|
24
|
+
import { ResourceTreeView } from '@/components/shared/resource-tree';
|
|
25
|
+
import { useResourceTree } from '@/hooks/use-resource-tree';
|
|
26
|
+
|
|
27
|
+
export default function StatefulSetDetailPage() {
|
|
28
|
+
const params = useParams();
|
|
29
|
+
const router = useRouter();
|
|
30
|
+
const clusterId = params.clusterId as string;
|
|
31
|
+
const namespace = params.namespace as string;
|
|
32
|
+
const name = params.name as string;
|
|
33
|
+
const [scaleOpen, setScaleOpen] = useState(false);
|
|
34
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
35
|
+
const [deleting, setDeleting] = useState(false);
|
|
36
|
+
const [restarting, setRestarting] = useState(false);
|
|
37
|
+
const { addTab } = usePanelStore();
|
|
38
|
+
|
|
39
|
+
const decodedClusterId = decodeURIComponent(clusterId);
|
|
40
|
+
|
|
41
|
+
const { data: sts, error, isLoading, mutate } = useResourceDetail({
|
|
42
|
+
clusterId: decodedClusterId,
|
|
43
|
+
namespace,
|
|
44
|
+
resourceType: 'statefulsets',
|
|
45
|
+
name,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Resource tree
|
|
49
|
+
const { nodes: treeNodes, edges: treeEdges, isLoading: treeLoading } = useResourceTree({
|
|
50
|
+
clusterId: decodedClusterId,
|
|
51
|
+
namespace,
|
|
52
|
+
rootKind: 'StatefulSet',
|
|
53
|
+
rootName: name,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Get pods belonging to this statefulset
|
|
57
|
+
const { data: podsData } = useResourceList({
|
|
58
|
+
clusterId: decodedClusterId,
|
|
59
|
+
namespace,
|
|
60
|
+
resourceType: 'pods',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (isLoading) return <LoadingSkeleton />;
|
|
64
|
+
if (error) return <ErrorDisplay error={error} onRetry={() => mutate()} clusterId={clusterId} />;
|
|
65
|
+
if (!sts) return null;
|
|
66
|
+
|
|
67
|
+
const metadata = sts.metadata || {};
|
|
68
|
+
const spec = sts.spec || {};
|
|
69
|
+
const status = sts.status || {};
|
|
70
|
+
const labels = metadata.labels || {};
|
|
71
|
+
|
|
72
|
+
// Filter pods by statefulset ownership
|
|
73
|
+
const stsPods = (podsData?.items || []).filter((p: any) =>
|
|
74
|
+
p.metadata?.ownerReferences?.some((ref: any) => ref.kind === 'StatefulSet' && ref.name === name)
|
|
75
|
+
|| p.metadata?.name?.startsWith(`${name}-`)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const handleRestart = async () => {
|
|
79
|
+
setRestarting(true);
|
|
80
|
+
try {
|
|
81
|
+
await apiClient.patch(`/api/clusters/${clusterId}/resources/${namespace}/statefulsets/${name}`, {
|
|
82
|
+
spec: { template: { metadata: { annotations: { 'kubectl.kubernetes.io/restartedAt': new Date().toISOString() } } } }
|
|
83
|
+
});
|
|
84
|
+
toast.success(`${name} restarting...`);
|
|
85
|
+
mutate();
|
|
86
|
+
} catch (err: any) {
|
|
87
|
+
toast.error(`Restart failed: ${err.message}`);
|
|
88
|
+
} finally { setRestarting(false); }
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const handleDelete = async () => {
|
|
92
|
+
setDeleting(true);
|
|
93
|
+
try {
|
|
94
|
+
await apiClient.delete(`/api/clusters/${clusterId}/resources/${namespace}/statefulsets/${name}`);
|
|
95
|
+
toast.success(`${name} deleted`);
|
|
96
|
+
router.back();
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
toast.error(`Delete failed: ${err.message}`);
|
|
99
|
+
} finally {
|
|
100
|
+
setDeleting(false);
|
|
101
|
+
setDeleteOpen(false);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="flex flex-col gap-4 p-6">
|
|
107
|
+
<div className="flex items-center gap-3">
|
|
108
|
+
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
109
|
+
<ArrowLeft className="h-4 w-4" />
|
|
110
|
+
</Button>
|
|
111
|
+
<div className="flex-1">
|
|
112
|
+
<div className="flex items-center gap-2">
|
|
113
|
+
<h1 className="text-2xl font-bold">{name}</h1>
|
|
114
|
+
<StatusBadge status={status.readyReplicas === spec.replicas ? 'Ready' : 'Pending'} />
|
|
115
|
+
</div>
|
|
116
|
+
<p className="text-sm text-muted-foreground">StatefulSet in {namespace} - Ready: {status.readyReplicas || 0}/{spec.replicas || 0}</p>
|
|
117
|
+
</div>
|
|
118
|
+
<div className="flex gap-2">
|
|
119
|
+
<Button variant="outline" size="sm" onClick={handleRestart} disabled={restarting}>
|
|
120
|
+
<RotateCcw className={`h-4 w-4 mr-1 ${restarting ? 'animate-spin' : ''}`} />
|
|
121
|
+
{restarting ? 'Restarting...' : 'Restart'}
|
|
122
|
+
</Button>
|
|
123
|
+
<Button variant="outline" size="sm" onClick={() => setScaleOpen(true)}>
|
|
124
|
+
<Scaling className="h-4 w-4 mr-1" />
|
|
125
|
+
Scale
|
|
126
|
+
</Button>
|
|
127
|
+
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
|
|
128
|
+
<Trash2 className="h-4 w-4 mr-1" />
|
|
129
|
+
Delete
|
|
130
|
+
</Button>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<Tabs defaultValue="overview">
|
|
135
|
+
<TabsList>
|
|
136
|
+
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
137
|
+
<TabsTrigger value="yaml">YAML</TabsTrigger>
|
|
138
|
+
</TabsList>
|
|
139
|
+
|
|
140
|
+
<TabsContent value="overview" className="space-y-4 mt-4">
|
|
141
|
+
{/* Resource Tree */}
|
|
142
|
+
<div className="space-y-2">
|
|
143
|
+
<h3 className="text-sm font-semibold">Resource Tree</h3>
|
|
144
|
+
<ResourceTreeView
|
|
145
|
+
treeNodes={treeNodes}
|
|
146
|
+
treeEdges={treeEdges}
|
|
147
|
+
isLoading={treeLoading}
|
|
148
|
+
height={300}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
153
|
+
<div className="space-y-3">
|
|
154
|
+
<h3 className="text-sm font-semibold">StatefulSet Info</h3>
|
|
155
|
+
<div className="rounded-md border p-3 space-y-2 text-sm">
|
|
156
|
+
<div className="flex justify-between"><span className="text-muted-foreground">Replicas</span><span>{spec.replicas || 0}</span></div>
|
|
157
|
+
<div className="flex justify-between"><span className="text-muted-foreground">Ready</span><span>{status.readyReplicas || 0}</span></div>
|
|
158
|
+
<div className="flex justify-between"><span className="text-muted-foreground">Current</span><span>{status.currentReplicas || 0}</span></div>
|
|
159
|
+
<div className="flex justify-between"><span className="text-muted-foreground">Service Name</span><span className="font-mono">{spec.serviceName || '-'}</span></div>
|
|
160
|
+
<div className="flex justify-between"><span className="text-muted-foreground">Update Strategy</span><span>{spec.updateStrategy?.type || '-'}</span></div>
|
|
161
|
+
<div className="flex justify-between"><span className="text-muted-foreground">Age</span><AgeDisplay timestamp={metadata.creationTimestamp} /></div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="space-y-3">
|
|
165
|
+
<h3 className="text-sm font-semibold">Labels</h3>
|
|
166
|
+
<div className="rounded-md border p-3 flex flex-wrap gap-1">
|
|
167
|
+
{Object.entries(labels).length > 0 ? (
|
|
168
|
+
Object.entries(labels).map(([k, v]) => (
|
|
169
|
+
<Badge key={k} variant="secondary" className="text-xs font-mono">{k}={v as string}</Badge>
|
|
170
|
+
))
|
|
171
|
+
) : (
|
|
172
|
+
<span className="text-sm text-muted-foreground">No labels</span>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Pod Metrics */}
|
|
179
|
+
{stsPods.length > 0 && (
|
|
180
|
+
<PodMetricsCharts
|
|
181
|
+
clusterId={decodedClusterId}
|
|
182
|
+
namespace={namespace}
|
|
183
|
+
podName={stsPods[0].metadata?.name}
|
|
184
|
+
nodeName={stsPods[0].spec?.nodeName}
|
|
185
|
+
/>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{/* Containers & Ports */}
|
|
189
|
+
{(spec.template?.spec?.containers || []).length > 0 && (
|
|
190
|
+
<div className="space-y-3">
|
|
191
|
+
<h3 className="text-sm font-semibold">Containers</h3>
|
|
192
|
+
{(spec.template?.spec?.containers || []).map((ctr: any, idx: number) => (
|
|
193
|
+
<div key={idx} className="rounded-md border p-3 space-y-2 text-sm">
|
|
194
|
+
<div className="flex justify-between items-center">
|
|
195
|
+
<span className="font-semibold">{ctr.name}</span>
|
|
196
|
+
<span className="font-mono text-xs text-muted-foreground break-all text-right max-w-[300px]">{ctr.image}</span>
|
|
197
|
+
</div>
|
|
198
|
+
{ctr.ports && ctr.ports.length > 0 && (
|
|
199
|
+
<div className="flex flex-wrap gap-2 pt-1">
|
|
200
|
+
{ctr.ports.map((p: any, pi: number) => (
|
|
201
|
+
<div key={pi} className="inline-flex items-center gap-1.5">
|
|
202
|
+
<Badge variant="outline" className="font-mono text-[11px] font-normal">
|
|
203
|
+
{p.containerPort}/{p.protocol || 'TCP'}
|
|
204
|
+
{p.name && <span className="text-muted-foreground ml-1">({p.name})</span>}
|
|
205
|
+
</Badge>
|
|
206
|
+
{stsPods.length > 0 && (
|
|
207
|
+
<PortForwardBtn
|
|
208
|
+
clusterId={decodedClusterId}
|
|
209
|
+
namespace={namespace}
|
|
210
|
+
resourceType="pod"
|
|
211
|
+
resourceName={stsPods[0].metadata?.name}
|
|
212
|
+
port={p.containerPort}
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
))}
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{/* Pods */}
|
|
225
|
+
<div className="space-y-3">
|
|
226
|
+
<h3 className="text-sm font-semibold">Pods ({stsPods.length})</h3>
|
|
227
|
+
{stsPods.length === 0 ? (
|
|
228
|
+
<p className="text-sm text-muted-foreground">No pods found for this StatefulSet.</p>
|
|
229
|
+
) : (
|
|
230
|
+
<div className="rounded-md border overflow-hidden">
|
|
231
|
+
<table className="w-full text-sm">
|
|
232
|
+
<thead className="bg-muted/50">
|
|
233
|
+
<tr>
|
|
234
|
+
<th className="px-3 py-2 text-left font-medium">Name</th>
|
|
235
|
+
<th className="px-3 py-2 text-left font-medium">Status</th>
|
|
236
|
+
<th className="px-3 py-2 text-left font-medium">Restarts</th>
|
|
237
|
+
<th className="px-3 py-2 text-left font-medium">Node</th>
|
|
238
|
+
<th className="px-3 py-2 text-left font-medium">Age</th>
|
|
239
|
+
<th className="px-3 py-2 text-left font-medium w-[80px]"></th>
|
|
240
|
+
</tr>
|
|
241
|
+
</thead>
|
|
242
|
+
<tbody>
|
|
243
|
+
{stsPods.map((pod: any) => {
|
|
244
|
+
const pName = pod.metadata?.name;
|
|
245
|
+
const pPhase = pod.metadata?.deletionTimestamp ? 'Terminating' : (pod.status?.phase || 'Unknown');
|
|
246
|
+
const restarts = (pod.status?.containerStatuses || []).reduce((s: number, c: any) => s + (c.restartCount || 0), 0);
|
|
247
|
+
const firstContainer = pod.spec?.containers?.[0]?.name;
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<tr key={pName} className="border-t hover:bg-muted/30">
|
|
251
|
+
<td className="px-3 py-2">
|
|
252
|
+
<Link href={`/clusters/${clusterId}/namespaces/${namespace}/pods/${pName}`} className="text-primary hover:underline font-medium">
|
|
253
|
+
{pName}
|
|
254
|
+
</Link>
|
|
255
|
+
</td>
|
|
256
|
+
<td className="px-3 py-2"><StatusBadge status={pPhase} /></td>
|
|
257
|
+
<td className="px-3 py-2">{restarts > 0 ? <span className="text-red-500 font-medium">{restarts}</span> : 0}</td>
|
|
258
|
+
<td className="px-3 py-2 font-mono text-muted-foreground text-xs">{pod.spec?.nodeName?.split('.')[0] || '-'}</td>
|
|
259
|
+
<td className="px-3 py-2"><AgeDisplay timestamp={pod.metadata?.creationTimestamp} /></td>
|
|
260
|
+
<td className="px-3 py-2">
|
|
261
|
+
<div className="flex gap-1">
|
|
262
|
+
<Button variant="ghost" size="icon" className="h-7 w-7" title="Logs" onClick={() => firstContainer && addTab({ id: `logs-${pName}-${firstContainer}`, type: 'logs', title: `Logs: ${pName}`, clusterId, namespace, podName: pName, container: firstContainer })}>
|
|
263
|
+
<ScrollText className="h-3.5 w-3.5" />
|
|
264
|
+
</Button>
|
|
265
|
+
<Button variant="ghost" size="icon" className="h-7 w-7" title="Exec" onClick={() => firstContainer && addTab({ id: `exec-${pName}-${firstContainer}`, type: 'exec', title: `Exec: ${pName}`, clusterId, namespace, podName: pName, container: firstContainer })}>
|
|
266
|
+
<Terminal className="h-3.5 w-3.5" />
|
|
267
|
+
</Button>
|
|
268
|
+
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" title="Delete Pod" onClick={async () => {
|
|
269
|
+
if (!confirm(`Delete pod ${pName}?`)) return;
|
|
270
|
+
try {
|
|
271
|
+
await apiClient.delete(`/api/clusters/${clusterId}/resources/${namespace}/pods/${pName}`);
|
|
272
|
+
toast.success(`${pName} deleted`);
|
|
273
|
+
} catch (err: any) { toast.error(err.message); }
|
|
274
|
+
}}>
|
|
275
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
276
|
+
</Button>
|
|
277
|
+
</div>
|
|
278
|
+
</td>
|
|
279
|
+
</tr>
|
|
280
|
+
);
|
|
281
|
+
})}
|
|
282
|
+
</tbody>
|
|
283
|
+
</table>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
</TabsContent>
|
|
288
|
+
|
|
289
|
+
<TabsContent value="yaml" className="mt-4">
|
|
290
|
+
<YamlEditor
|
|
291
|
+
data={sts}
|
|
292
|
+
apiUrl={`/api/clusters/${clusterId}/resources/${namespace}/statefulsets/${name}`}
|
|
293
|
+
onSaved={() => mutate()}
|
|
294
|
+
/>
|
|
295
|
+
</TabsContent>
|
|
296
|
+
</Tabs>
|
|
297
|
+
|
|
298
|
+
<ScaleDialog open={scaleOpen} onOpenChange={setScaleOpen} clusterId={decodedClusterId} namespace={namespace} resourceType="statefulsets" name={name} currentReplicas={spec.replicas || 0} onScaled={() => mutate()} />
|
|
299
|
+
<ConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} title={`Delete ${name}?`} description={`This will permanently delete the statefulset "${name}".`} confirmLabel="Delete" variant="destructive" onConfirm={handleDelete} loading={deleting} />
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|