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.
Files changed (160) hide show
  1. package/README.md +219 -0
  2. package/electron/main.js +429 -0
  3. package/electron/preload.js +13 -0
  4. package/electron-builder.yml +60 -0
  5. package/next.config.ts +8 -0
  6. package/package.json +98 -0
  7. package/postcss.config.mjs +7 -0
  8. package/resources/icon.icns +0 -0
  9. package/resources/icon.ico +0 -0
  10. package/resources/icon.png +0 -0
  11. package/resources/icon.svg +86 -0
  12. package/scripts/build-server.mjs +20 -0
  13. package/scripts/generate-icons.mjs +61 -0
  14. package/server.ts +58 -0
  15. package/src/app/api/clusters/[clusterId]/health/route.ts +27 -0
  16. package/src/app/api/clusters/[clusterId]/metrics/route.ts +196 -0
  17. package/src/app/api/clusters/[clusterId]/namespaces/route.ts +48 -0
  18. package/src/app/api/clusters/[clusterId]/nodes/[nodeName]/route.ts +21 -0
  19. package/src/app/api/clusters/[clusterId]/nodes/route.ts +31 -0
  20. package/src/app/api/clusters/[clusterId]/resources/[namespace]/[...resourcePath]/route.ts +204 -0
  21. package/src/app/api/clusters/route.ts +48 -0
  22. package/src/app/api/kubeconfig/route.ts +25 -0
  23. package/src/app/api/port-forward/route.ts +143 -0
  24. package/src/app/api/tsh/login/route.ts +50 -0
  25. package/src/app/clusters/[clusterId]/app-map/page.tsx +42 -0
  26. package/src/app/clusters/[clusterId]/clusterrolebindings/[name]/page.tsx +5 -0
  27. package/src/app/clusters/[clusterId]/clusterrolebindings/page.tsx +5 -0
  28. package/src/app/clusters/[clusterId]/clusterroles/[name]/page.tsx +5 -0
  29. package/src/app/clusters/[clusterId]/clusterroles/page.tsx +5 -0
  30. package/src/app/clusters/[clusterId]/layout.tsx +9 -0
  31. package/src/app/clusters/[clusterId]/namespaces/[namespace]/configmaps/[name]/page.tsx +5 -0
  32. package/src/app/clusters/[clusterId]/namespaces/[namespace]/configmaps/page.tsx +5 -0
  33. package/src/app/clusters/[clusterId]/namespaces/[namespace]/cronjobs/[name]/page.tsx +5 -0
  34. package/src/app/clusters/[clusterId]/namespaces/[namespace]/cronjobs/page.tsx +5 -0
  35. package/src/app/clusters/[clusterId]/namespaces/[namespace]/daemonsets/[name]/page.tsx +5 -0
  36. package/src/app/clusters/[clusterId]/namespaces/[namespace]/daemonsets/page.tsx +5 -0
  37. package/src/app/clusters/[clusterId]/namespaces/[namespace]/deployments/[name]/page.tsx +457 -0
  38. package/src/app/clusters/[clusterId]/namespaces/[namespace]/deployments/page.tsx +5 -0
  39. package/src/app/clusters/[clusterId]/namespaces/[namespace]/endpoints/[name]/page.tsx +5 -0
  40. package/src/app/clusters/[clusterId]/namespaces/[namespace]/endpoints/page.tsx +5 -0
  41. package/src/app/clusters/[clusterId]/namespaces/[namespace]/events/page.tsx +5 -0
  42. package/src/app/clusters/[clusterId]/namespaces/[namespace]/ingresses/[name]/page.tsx +5 -0
  43. package/src/app/clusters/[clusterId]/namespaces/[namespace]/ingresses/page.tsx +5 -0
  44. package/src/app/clusters/[clusterId]/namespaces/[namespace]/jobs/[name]/page.tsx +5 -0
  45. package/src/app/clusters/[clusterId]/namespaces/[namespace]/jobs/page.tsx +5 -0
  46. package/src/app/clusters/[clusterId]/namespaces/[namespace]/networkpolicies/[name]/page.tsx +5 -0
  47. package/src/app/clusters/[clusterId]/namespaces/[namespace]/networkpolicies/page.tsx +5 -0
  48. package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/exec/page.tsx +173 -0
  49. package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/logs/page.tsx +137 -0
  50. package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/page.tsx +448 -0
  51. package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/page.tsx +5 -0
  52. package/src/app/clusters/[clusterId]/namespaces/[namespace]/pvcs/[name]/page.tsx +5 -0
  53. package/src/app/clusters/[clusterId]/namespaces/[namespace]/pvcs/page.tsx +5 -0
  54. package/src/app/clusters/[clusterId]/namespaces/[namespace]/replicasets/[name]/page.tsx +5 -0
  55. package/src/app/clusters/[clusterId]/namespaces/[namespace]/replicasets/page.tsx +5 -0
  56. package/src/app/clusters/[clusterId]/namespaces/[namespace]/rolebindings/[name]/page.tsx +5 -0
  57. package/src/app/clusters/[clusterId]/namespaces/[namespace]/rolebindings/page.tsx +5 -0
  58. package/src/app/clusters/[clusterId]/namespaces/[namespace]/roles/[name]/page.tsx +5 -0
  59. package/src/app/clusters/[clusterId]/namespaces/[namespace]/roles/page.tsx +5 -0
  60. package/src/app/clusters/[clusterId]/namespaces/[namespace]/secrets/[name]/page.tsx +168 -0
  61. package/src/app/clusters/[clusterId]/namespaces/[namespace]/secrets/page.tsx +5 -0
  62. package/src/app/clusters/[clusterId]/namespaces/[namespace]/serviceaccounts/[name]/page.tsx +5 -0
  63. package/src/app/clusters/[clusterId]/namespaces/[namespace]/serviceaccounts/page.tsx +5 -0
  64. package/src/app/clusters/[clusterId]/namespaces/[namespace]/services/[name]/page.tsx +5 -0
  65. package/src/app/clusters/[clusterId]/namespaces/[namespace]/services/page.tsx +5 -0
  66. package/src/app/clusters/[clusterId]/namespaces/[namespace]/statefulsets/[name]/page.tsx +302 -0
  67. package/src/app/clusters/[clusterId]/namespaces/[namespace]/statefulsets/page.tsx +5 -0
  68. package/src/app/clusters/[clusterId]/nodes/[nodeName]/page.tsx +5 -0
  69. package/src/app/clusters/[clusterId]/nodes/page.tsx +5 -0
  70. package/src/app/clusters/[clusterId]/page.tsx +635 -0
  71. package/src/app/clusters/[clusterId]/port-forwarding/page.tsx +145 -0
  72. package/src/app/clusters/[clusterId]/pvs/[name]/page.tsx +5 -0
  73. package/src/app/clusters/[clusterId]/pvs/page.tsx +5 -0
  74. package/src/app/clusters/page.tsx +166 -0
  75. package/src/app/favicon.ico +0 -0
  76. package/src/app/globals.css +167 -0
  77. package/src/app/layout.tsx +48 -0
  78. package/src/app/page.tsx +5 -0
  79. package/src/components/clusters/cluster-selector.tsx +64 -0
  80. package/src/components/layout/app-shell.tsx +26 -0
  81. package/src/components/layout/breadcrumbs.tsx +97 -0
  82. package/src/components/layout/command-palette.tsx +112 -0
  83. package/src/components/layout/header.tsx +184 -0
  84. package/src/components/layout/sidebar.tsx +84 -0
  85. package/src/components/layout/theme-toggle.tsx +21 -0
  86. package/src/components/namespaces/namespace-selector.tsx +165 -0
  87. package/src/components/panel/bottom-panel.tsx +127 -0
  88. package/src/components/panel/logs-tab.tsx +109 -0
  89. package/src/components/panel/terminal-tab.tsx +180 -0
  90. package/src/components/pods/pod-watch-button.tsx +44 -0
  91. package/src/components/resources/resource-columns.tsx +320 -0
  92. package/src/components/resources/resource-detail-page.tsx +191 -0
  93. package/src/components/resources/resource-list-page.tsx +78 -0
  94. package/src/components/resources/scale-dialog.tsx +107 -0
  95. package/src/components/settings/settings-dialog.tsx +103 -0
  96. package/src/components/shared/age-display.tsx +27 -0
  97. package/src/components/shared/confirm-dialog.tsx +52 -0
  98. package/src/components/shared/data-table.tsx +149 -0
  99. package/src/components/shared/env-value-resolver.tsx +570 -0
  100. package/src/components/shared/error-display.tsx +109 -0
  101. package/src/components/shared/loading-skeleton.tsx +25 -0
  102. package/src/components/shared/metrics-charts-impl.tsx +434 -0
  103. package/src/components/shared/metrics-charts.tsx +24 -0
  104. package/src/components/shared/port-forward-btn.tsx +60 -0
  105. package/src/components/shared/resource-info-drawer.tsx +542 -0
  106. package/src/components/shared/resource-node.tsx +157 -0
  107. package/src/components/shared/resource-tree-impl.tsx +228 -0
  108. package/src/components/shared/resource-tree.tsx +20 -0
  109. package/src/components/shared/status-badge.tsx +35 -0
  110. package/src/components/shared/yaml-editor.tsx +438 -0
  111. package/src/components/ui/badge.tsx +48 -0
  112. package/src/components/ui/button.tsx +64 -0
  113. package/src/components/ui/card.tsx +92 -0
  114. package/src/components/ui/command.tsx +184 -0
  115. package/src/components/ui/dialog.tsx +158 -0
  116. package/src/components/ui/dropdown-menu.tsx +257 -0
  117. package/src/components/ui/input.tsx +21 -0
  118. package/src/components/ui/popover.tsx +89 -0
  119. package/src/components/ui/scroll-area.tsx +58 -0
  120. package/src/components/ui/select.tsx +190 -0
  121. package/src/components/ui/separator.tsx +28 -0
  122. package/src/components/ui/sheet.tsx +143 -0
  123. package/src/components/ui/skeleton.tsx +13 -0
  124. package/src/components/ui/sonner.tsx +40 -0
  125. package/src/components/ui/table.tsx +116 -0
  126. package/src/components/ui/tabs.tsx +91 -0
  127. package/src/components/ui/tooltip.tsx +57 -0
  128. package/src/hooks/use-age-tick.ts +40 -0
  129. package/src/hooks/use-auto-update.ts +100 -0
  130. package/src/hooks/use-clusters.ts +32 -0
  131. package/src/hooks/use-namespaces.ts +17 -0
  132. package/src/hooks/use-pod-watcher.ts +79 -0
  133. package/src/hooks/use-port-forwards.ts +18 -0
  134. package/src/hooks/use-resource-detail.ts +28 -0
  135. package/src/hooks/use-resource-list.ts +26 -0
  136. package/src/hooks/use-resource-tree.ts +440 -0
  137. package/src/lib/api-client.ts +31 -0
  138. package/src/lib/constants.ts +126 -0
  139. package/src/lib/k8s/client-factory.ts +57 -0
  140. package/src/lib/k8s/kubeconfig-manager.ts +43 -0
  141. package/src/lib/k8s/resource-api.ts +223 -0
  142. package/src/lib/k8s/types.ts +29 -0
  143. package/src/lib/utils.ts +6 -0
  144. package/src/providers/swr-provider.tsx +20 -0
  145. package/src/providers/theme-provider.tsx +17 -0
  146. package/src/stores/cluster-store.ts +32 -0
  147. package/src/stores/namespace-store.ts +27 -0
  148. package/src/stores/panel-store.ts +61 -0
  149. package/src/stores/pod-watcher-store.ts +69 -0
  150. package/src/stores/settings-store.ts +24 -0
  151. package/src/stores/sidebar-store.ts +22 -0
  152. package/src/types/cluster.ts +19 -0
  153. package/src/types/css.d.ts +6 -0
  154. package/src/types/electron.d.ts +25 -0
  155. package/src/types/navigation.ts +4 -0
  156. package/src/types/resource.ts +27 -0
  157. package/tsconfig.json +34 -0
  158. package/ws/exec-handler.ts +112 -0
  159. package/ws/index.ts +2 -0
  160. 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,5 @@
1
+ 'use client';
2
+ import { ResourceListPage } from '@/components/resources/resource-list-page';
3
+ export default function SecretsPage() {
4
+ return <ResourceListPage resourceType="secrets" />;
5
+ }
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import { ResourceDetailPage } from '@/components/resources/resource-detail-page';
3
+ export default function ServiceAccountDetailPage() {
4
+ return <ResourceDetailPage resourceType="serviceaccounts" />;
5
+ }
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import { ResourceListPage } from '@/components/resources/resource-list-page';
3
+ export default function ServiceAccountsPage() {
4
+ return <ResourceListPage resourceType="serviceaccounts" />;
5
+ }
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import { ResourceDetailPage } from '@/components/resources/resource-detail-page';
3
+ export default function ServiceDetailPage() {
4
+ return <ResourceDetailPage resourceType="services" />;
5
+ }
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import { ResourceListPage } from '@/components/resources/resource-list-page';
3
+ export default function ServicesPage() {
4
+ return <ResourceListPage resourceType="services" />;
5
+ }
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import { ResourceListPage } from '@/components/resources/resource-list-page';
3
+ export default function StatefulSetsPage() {
4
+ return <ResourceListPage resourceType="statefulsets" />;
5
+ }
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import { ResourceDetailPage } from '@/components/resources/resource-detail-page';
3
+ export default function NodeDetailPage() {
4
+ return <ResourceDetailPage resourceType="nodes" clusterScoped={true} />;
5
+ }
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import { ResourceListPage } from '@/components/resources/resource-list-page';
3
+ export default function NodesPage() {
4
+ return <ResourceListPage resourceType="nodes" clusterScoped={true} />;
5
+ }