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,320 @@
1
+ 'use client';
2
+
3
+ import { ColumnDef } from '@tanstack/react-table';
4
+ import { StatusBadge } from '@/components/shared/status-badge';
5
+ import { AgeDisplay } from '@/components/shared/age-display';
6
+ import { Badge } from '@/components/ui/badge';
7
+
8
+ const nsCol: ColumnDef<any> = {
9
+ accessorFn: (row) => row.metadata?.namespace,
10
+ id: 'namespace',
11
+ header: 'Namespace',
12
+ cell: ({ row }) => (
13
+ <Badge variant="outline" className="font-normal text-xs">
14
+ {row.original.metadata?.namespace}
15
+ </Badge>
16
+ ),
17
+ };
18
+
19
+ function getPodStatus(pod: any): string {
20
+ if (pod.metadata?.deletionTimestamp) return 'Terminating';
21
+ return pod.status?.phase || 'Unknown';
22
+ }
23
+
24
+ function getPodRestarts(pod: any): number {
25
+ return (pod.status?.containerStatuses || []).reduce(
26
+ (sum: number, cs: any) => sum + (cs.restartCount || 0),
27
+ 0
28
+ );
29
+ }
30
+
31
+ function getReadyContainers(pod: any): string {
32
+ const statuses = pod.status?.containerStatuses || [];
33
+ const ready = statuses.filter((cs: any) => cs.ready).length;
34
+ return `${ready}/${statuses.length || pod.spec?.containers?.length || 0}`;
35
+ }
36
+
37
+ function getDeploymentReady(dep: any): string {
38
+ const ready = dep.status?.readyReplicas || 0;
39
+ const desired = dep.spec?.replicas || 0;
40
+ return `${ready}/${desired}`;
41
+ }
42
+
43
+ export const podColumns: ColumnDef<any>[] = [
44
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
45
+ nsCol,
46
+ { id: 'status', header: 'Status', cell: ({ row }) => <StatusBadge status={getPodStatus(row.original)} /> },
47
+ { id: 'ready', header: 'Ready', cell: ({ row }) => getReadyContainers(row.original) },
48
+ { id: 'restarts', header: 'Restarts', cell: ({ row }) => getPodRestarts(row.original) },
49
+ { id: 'node', header: 'Node', cell: ({ row }) => row.original.spec?.nodeName || '-' },
50
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
51
+ ];
52
+
53
+ export const deploymentColumns: ColumnDef<any>[] = [
54
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
55
+ nsCol,
56
+ { id: 'ready', header: 'Ready', cell: ({ row }) => getDeploymentReady(row.original) },
57
+ { id: 'upToDate', header: 'Up-to-date', cell: ({ row }) => row.original.status?.updatedReplicas || 0 },
58
+ { id: 'available', header: 'Available', cell: ({ row }) => row.original.status?.availableReplicas || 0 },
59
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
60
+ ];
61
+
62
+ export const serviceColumns: ColumnDef<any>[] = [
63
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
64
+ nsCol,
65
+ { id: 'type', header: 'Type', cell: ({ row }) => {
66
+ const type = row.original.spec?.type || 'ClusterIP';
67
+ return <Badge variant={type === 'LoadBalancer' ? 'default' : type === 'NodePort' ? 'secondary' : 'outline'} className="text-xs font-mono">{type}</Badge>;
68
+ }},
69
+ { id: 'clusterIP', header: 'Cluster IP', cell: ({ row }) => <span className="font-mono text-xs">{row.original.spec?.clusterIP || '-'}</span> },
70
+ { id: 'externalIP', header: 'External IP', cell: ({ row }) => {
71
+ const svc = row.original;
72
+ const lbIngress = svc.status?.loadBalancer?.ingress;
73
+ if (lbIngress?.length) return <span className="font-mono text-xs">{lbIngress[0].hostname || lbIngress[0].ip || '-'}</span>;
74
+ const externalIPs = svc.spec?.externalIPs;
75
+ if (externalIPs?.length) return <span className="font-mono text-xs">{externalIPs.join(', ')}</span>;
76
+ return <span className="text-muted-foreground">-</span>;
77
+ }},
78
+ { id: 'ports', header: 'Ports', cell: ({ row }) => {
79
+ const ports = row.original.spec?.ports || [];
80
+ return <span className="font-mono text-xs">{ports.map((p: any) => {
81
+ let s = `${p.port}`;
82
+ if (p.nodePort) s += `:${p.nodePort}`;
83
+ s += `/${p.protocol || 'TCP'}`;
84
+ return s;
85
+ }).join(', ') || '-'}</span>;
86
+ }},
87
+ { id: 'selector', header: 'Selector', cell: ({ row }) => {
88
+ const sel = row.original.spec?.selector || {};
89
+ const entries = Object.entries(sel);
90
+ if (entries.length === 0) return <span className="text-muted-foreground">-</span>;
91
+ return <span className="text-xs font-mono truncate max-w-[200px] block">{entries.map(([k, v]) => `${k}=${v}`).join(', ')}</span>;
92
+ }},
93
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
94
+ ];
95
+
96
+ export const statefulsetColumns: ColumnDef<any>[] = [
97
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
98
+ nsCol,
99
+ { id: 'ready', header: 'Ready', cell: ({ row }) => `${row.original.status?.readyReplicas || 0}/${row.original.spec?.replicas || 0}` },
100
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
101
+ ];
102
+
103
+ export const daemonsetColumns: ColumnDef<any>[] = [
104
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
105
+ nsCol,
106
+ { id: 'desired', header: 'Desired', cell: ({ row }) => row.original.status?.desiredNumberScheduled || 0 },
107
+ { id: 'current', header: 'Current', cell: ({ row }) => row.original.status?.currentNumberScheduled || 0 },
108
+ { id: 'ready', header: 'Ready', cell: ({ row }) => row.original.status?.numberReady || 0 },
109
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
110
+ ];
111
+
112
+ export const replicasetColumns: ColumnDef<any>[] = [
113
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
114
+ nsCol,
115
+ { id: 'desired', header: 'Desired', cell: ({ row }) => row.original.spec?.replicas || 0 },
116
+ { id: 'current', header: 'Current', cell: ({ row }) => row.original.status?.replicas || 0 },
117
+ { id: 'ready', header: 'Ready', cell: ({ row }) => row.original.status?.readyReplicas || 0 },
118
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
119
+ ];
120
+
121
+ export const jobColumns: ColumnDef<any>[] = [
122
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
123
+ nsCol,
124
+ { id: 'completions', header: 'Completions', cell: ({ row }) => `${row.original.status?.succeeded || 0}/${row.original.spec?.completions || 1}` },
125
+ { id: 'status', header: 'Status', cell: ({ row }) => {
126
+ if (row.original.status?.succeeded) return <StatusBadge status="Succeeded" />;
127
+ if (row.original.status?.failed) return <StatusBadge status="Failed" />;
128
+ if (row.original.status?.active) return <StatusBadge status="Running" />;
129
+ return <StatusBadge status="Pending" />;
130
+ }},
131
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
132
+ ];
133
+
134
+ export const cronjobColumns: ColumnDef<any>[] = [
135
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
136
+ nsCol,
137
+ { id: 'schedule', header: 'Schedule', cell: ({ row }) => row.original.spec?.schedule || '-' },
138
+ { id: 'suspend', header: 'Suspend', cell: ({ row }) => row.original.spec?.suspend ? 'Yes' : 'No' },
139
+ { id: 'active', header: 'Active', cell: ({ row }) => (row.original.status?.active || []).length },
140
+ { id: 'lastSchedule', header: 'Last Schedule', cell: ({ row }) => <AgeDisplay timestamp={row.original.status?.lastScheduleTime} /> },
141
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
142
+ ];
143
+
144
+ export const ingressColumns: ColumnDef<any>[] = [
145
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
146
+ nsCol,
147
+ { id: 'class', header: 'Class', cell: ({ row }) => row.original.spec?.ingressClassName || row.original.metadata?.annotations?.['kubernetes.io/ingress.class'] || '-' },
148
+ { id: 'hosts', header: 'Hosts', cell: ({ row }) => {
149
+ const hosts = (row.original.spec?.rules || []).map((r: any) => r.host).filter(Boolean);
150
+ return hosts.length > 0 ? <span className="font-mono text-xs">{hosts.join(', ')}</span> : '*';
151
+ }},
152
+ { id: 'loadBalancer', header: 'Load Balancer', cell: ({ row }) => {
153
+ const ingress = row.original.status?.loadBalancer?.ingress;
154
+ if (!ingress?.length) return <span className="text-muted-foreground">-</span>;
155
+ const addr = ingress[0].hostname || ingress[0].ip || '-';
156
+ return <span className="font-mono text-xs truncate max-w-[200px] block">{addr}</span>;
157
+ }},
158
+ { id: 'rules', header: 'Rules', cell: ({ row }) => {
159
+ const rules = row.original.spec?.rules || [];
160
+ const pathCount = rules.reduce((sum: number, r: any) => sum + (r.http?.paths?.length || 0), 0);
161
+ return `${rules.length} rules, ${pathCount} paths`;
162
+ }},
163
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
164
+ ];
165
+
166
+ export const configmapColumns: ColumnDef<any>[] = [
167
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
168
+ nsCol,
169
+ { id: 'data', header: 'Data', cell: ({ row }) => Object.keys(row.original.data || {}).length },
170
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
171
+ ];
172
+
173
+ export const secretColumns: ColumnDef<any>[] = [
174
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
175
+ nsCol,
176
+ { id: 'type', header: 'Type', cell: ({ row }) => row.original.type || '-' },
177
+ { id: 'data', header: 'Data', cell: ({ row }) => Object.keys(row.original.data || {}).length },
178
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
179
+ ];
180
+
181
+ export const pvcColumns: ColumnDef<any>[] = [
182
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
183
+ nsCol,
184
+ { id: 'status', header: 'Status', cell: ({ row }) => <StatusBadge status={row.original.status?.phase || 'Unknown'} /> },
185
+ { id: 'volume', header: 'Volume', cell: ({ row }) => row.original.spec?.volumeName || '-' },
186
+ { id: 'capacity', header: 'Capacity', cell: ({ row }) => row.original.status?.capacity?.storage || '-' },
187
+ { id: 'storageClass', header: 'Storage Class', cell: ({ row }) => row.original.spec?.storageClassName || '-' },
188
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
189
+ ];
190
+
191
+ export const serviceaccountColumns: ColumnDef<any>[] = [
192
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
193
+ nsCol,
194
+ { id: 'secrets', header: 'Secrets', cell: ({ row }) => (row.original.secrets || []).length },
195
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
196
+ ];
197
+
198
+ export const roleColumns: ColumnDef<any>[] = [
199
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
200
+ nsCol,
201
+ { id: 'rules', header: 'Rules', cell: ({ row }) => (row.original.rules || []).length },
202
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
203
+ ];
204
+
205
+ export const rolebindingColumns: ColumnDef<any>[] = [
206
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
207
+ nsCol,
208
+ { id: 'role', header: 'Role', cell: ({ row }) => `${row.original.roleRef?.kind}/${row.original.roleRef?.name}` },
209
+ { id: 'subjects', header: 'Subjects', cell: ({ row }) => (row.original.subjects || []).length },
210
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
211
+ ];
212
+
213
+ export const networkpolicyColumns: ColumnDef<any>[] = [
214
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
215
+ nsCol,
216
+ { id: 'podSelector', header: 'Pod Selector', cell: ({ row }) => {
217
+ const labels = row.original.spec?.podSelector?.matchLabels || {};
218
+ const entries = Object.entries(labels);
219
+ return entries.length > 0 ? entries.map(([k, v]) => `${k}=${v}`).join(', ') : '<all>' ;
220
+ }},
221
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
222
+ ];
223
+
224
+ export const eventColumns: ColumnDef<any>[] = [
225
+ nsCol,
226
+ { id: 'type', header: 'Type', cell: ({ row }) => <StatusBadge status={row.original.type || 'Normal'} /> },
227
+ { id: 'reason', header: 'Reason', cell: ({ row }) => row.original.reason || '-' },
228
+ { id: 'object', header: 'Object', cell: ({ row }) => `${row.original.involvedObject?.kind}/${row.original.involvedObject?.name}` },
229
+ { accessorFn: (row) => row.message, id: 'message', header: 'Message', cell: ({ row }) => <span className="text-sm truncate max-w-md block">{row.original.message}</span> },
230
+ { id: 'count', header: 'Count', cell: ({ row }) => row.original.count || 1 },
231
+ { id: 'lastSeen', header: 'Last Seen', cell: ({ row }) => <AgeDisplay timestamp={row.original.lastTimestamp || row.original.metadata?.creationTimestamp} /> },
232
+ ];
233
+
234
+ export const nodeColumns: ColumnDef<any>[] = [
235
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
236
+ { id: 'status', header: 'Status', cell: ({ row }) => {
237
+ const ready = (row.original.status?.conditions || []).find((c: any) => c.type === 'Ready');
238
+ return <StatusBadge status={ready?.status === 'True' ? 'Ready' : 'NotReady'} />;
239
+ }},
240
+ { id: 'roles', header: 'Roles', cell: ({ row }) => {
241
+ const labels = row.original.metadata?.labels || {};
242
+ const roles = Object.keys(labels).filter(k => k.startsWith('node-role.kubernetes.io/')).map(k => k.split('/')[1]);
243
+ return roles.join(', ') || '-';
244
+ }},
245
+ { id: 'version', header: 'Version', cell: ({ row }) => row.original.status?.nodeInfo?.kubeletVersion || '-' },
246
+ { id: 'os', header: 'OS', cell: ({ row }) => row.original.status?.nodeInfo?.osImage || '-' },
247
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
248
+ ];
249
+
250
+ export const pvColumns: ColumnDef<any>[] = [
251
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
252
+ { id: 'capacity', header: 'Capacity', cell: ({ row }) => row.original.spec?.capacity?.storage || '-' },
253
+ { id: 'accessModes', header: 'Access Modes', cell: ({ row }) => (row.original.spec?.accessModes || []).join(', ') },
254
+ { id: 'status', header: 'Status', cell: ({ row }) => <StatusBadge status={row.original.status?.phase || 'Unknown'} /> },
255
+ { id: 'claim', header: 'Claim', cell: ({ row }) => row.original.spec?.claimRef ? `${row.original.spec.claimRef.namespace}/${row.original.spec.claimRef.name}` : '-' },
256
+ { id: 'storageClass', header: 'Storage Class', cell: ({ row }) => row.original.spec?.storageClassName || '-' },
257
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
258
+ ];
259
+
260
+ export const clusterroleColumns: ColumnDef<any>[] = [
261
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
262
+ { id: 'rules', header: 'Rules', cell: ({ row }) => (row.original.rules || []).length },
263
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
264
+ ];
265
+
266
+ export const clusterrolebindingColumns: ColumnDef<any>[] = [
267
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
268
+ { id: 'role', header: 'Role', cell: ({ row }) => `${row.original.roleRef?.kind}/${row.original.roleRef?.name}` },
269
+ { id: 'subjects', header: 'Subjects', cell: ({ row }) => (row.original.subjects || []).length },
270
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
271
+ ];
272
+
273
+ export const endpointColumns: ColumnDef<any>[] = [
274
+ { accessorFn: (row) => row.metadata?.name, id: 'name', header: 'Name' },
275
+ nsCol,
276
+ { id: 'endpoints', header: 'Endpoints', cell: ({ row }) => {
277
+ const subsets = row.original.subsets || [];
278
+ const addrs: string[] = [];
279
+ for (const s of subsets) {
280
+ const ports = (s.ports || []).map((p: any) => p.port);
281
+ for (const a of s.addresses || []) {
282
+ if (ports.length > 0) {
283
+ for (const port of ports) addrs.push(`${a.ip}:${port}`);
284
+ } else {
285
+ addrs.push(a.ip);
286
+ }
287
+ }
288
+ }
289
+ if (addrs.length === 0) return <span className="text-muted-foreground">None</span>;
290
+ const display = addrs.slice(0, 5).join(', ');
291
+ const more = addrs.length > 5 ? ` +${addrs.length - 5} more` : '';
292
+ return <span className="font-mono text-xs">{display}{more && <span className="text-muted-foreground">{more}</span>}</span>;
293
+ }},
294
+ { id: 'age', header: 'Age', cell: ({ row }) => <AgeDisplay timestamp={row.original.metadata?.creationTimestamp} /> },
295
+ ];
296
+
297
+ export const COLUMN_MAP: Record<string, ColumnDef<any>[]> = {
298
+ pods: podColumns,
299
+ deployments: deploymentColumns,
300
+ statefulsets: statefulsetColumns,
301
+ daemonsets: daemonsetColumns,
302
+ replicasets: replicasetColumns,
303
+ jobs: jobColumns,
304
+ cronjobs: cronjobColumns,
305
+ services: serviceColumns,
306
+ ingresses: ingressColumns,
307
+ configmaps: configmapColumns,
308
+ secrets: secretColumns,
309
+ pvcs: pvcColumns,
310
+ serviceaccounts: serviceaccountColumns,
311
+ roles: roleColumns,
312
+ rolebindings: rolebindingColumns,
313
+ networkpolicies: networkpolicyColumns,
314
+ events: eventColumns,
315
+ nodes: nodeColumns,
316
+ pvs: pvColumns,
317
+ clusterroles: clusterroleColumns,
318
+ clusterrolebindings: clusterrolebindingColumns,
319
+ endpoints: endpointColumns,
320
+ };
@@ -0,0 +1,191 @@
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 { ArrowLeft, Trash2 } from 'lucide-react';
11
+ import { RESOURCE_LABELS } from '@/lib/constants';
12
+ import { AgeDisplay } from '@/components/shared/age-display';
13
+ import { useState } from 'react';
14
+ import { ConfirmDialog } from '@/components/shared/confirm-dialog';
15
+ import { apiClient } from '@/lib/api-client';
16
+ import { toast } from 'sonner';
17
+ import { YamlEditor } from '@/components/shared/yaml-editor';
18
+
19
+ interface ResourceDetailPageProps {
20
+ resourceType: string;
21
+ clusterScoped?: boolean;
22
+ children?: React.ReactNode;
23
+ }
24
+
25
+ export function ResourceDetailPage({ resourceType, clusterScoped, children }: ResourceDetailPageProps) {
26
+ const params = useParams();
27
+ const router = useRouter();
28
+ const clusterId = params.clusterId as string;
29
+ const namespace = (params.namespace as string) || '_';
30
+ const name = (params.name as string) || (params.podName as string) || (params.nodeName as string);
31
+ const [deleteOpen, setDeleteOpen] = useState(false);
32
+ const [deleting, setDeleting] = useState(false);
33
+
34
+ const { data, error, isLoading, mutate } = useResourceDetail({
35
+ clusterId: clusterId ? decodeURIComponent(clusterId) : null,
36
+ namespace: clusterScoped ? '_' : namespace,
37
+ resourceType,
38
+ name,
39
+ });
40
+
41
+ const yamlApiUrl = clusterScoped
42
+ ? `/api/clusters/${clusterId}/resources/_/${resourceType}/${name}`
43
+ : `/api/clusters/${clusterId}/resources/${namespace}/${resourceType}/${name}`;
44
+
45
+ if (isLoading) return <LoadingSkeleton />;
46
+ if (error) return <ErrorDisplay error={error} onRetry={() => mutate()} clusterId={clusterId} />;
47
+ if (!data) return null;
48
+
49
+ const resource = data;
50
+ const metadata = resource.metadata || {};
51
+ const labels = metadata.labels || {};
52
+ const annotations = metadata.annotations || {};
53
+
54
+ const handleDelete = async () => {
55
+ setDeleting(true);
56
+ try {
57
+ const url = clusterScoped
58
+ ? `/api/clusters/${clusterId}/resources/_/${resourceType}/${name}`
59
+ : `/api/clusters/${clusterId}/resources/${namespace}/${resourceType}/${name}`;
60
+ await apiClient.delete(url);
61
+ toast.success(`${name} deleted`);
62
+ router.back();
63
+ } catch (err: any) {
64
+ toast.error(`Delete failed: ${err.message}`);
65
+ } finally {
66
+ setDeleting(false);
67
+ setDeleteOpen(false);
68
+ }
69
+ };
70
+
71
+ return (
72
+ <div className="flex flex-col gap-4 p-6">
73
+ <div className="flex items-center gap-3">
74
+ <Button variant="ghost" size="icon" onClick={() => router.back()}>
75
+ <ArrowLeft className="h-4 w-4" />
76
+ </Button>
77
+ <div className="flex-1">
78
+ <h1 className="text-2xl font-bold">{metadata.name}</h1>
79
+ <p className="text-sm text-muted-foreground">
80
+ {RESOURCE_LABELS[resourceType] || resourceType}
81
+ {metadata.namespace && ` in ${metadata.namespace}`}
82
+ </p>
83
+ </div>
84
+ <Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
85
+ <Trash2 className="h-4 w-4 mr-1" />
86
+ Delete
87
+ </Button>
88
+ </div>
89
+
90
+ <Tabs defaultValue="overview">
91
+ <TabsList>
92
+ <TabsTrigger value="overview">Overview</TabsTrigger>
93
+ {children && <TabsTrigger value="extra">Details</TabsTrigger>}
94
+ <TabsTrigger value="yaml">YAML</TabsTrigger>
95
+ </TabsList>
96
+
97
+ <TabsContent value="overview" className="space-y-4 mt-4">
98
+ <div className="grid gap-4 md:grid-cols-2">
99
+ <div className="space-y-3">
100
+ <h3 className="text-sm font-semibold">Metadata</h3>
101
+ <div className="rounded-md border p-3 space-y-2 text-sm">
102
+ <div className="flex justify-between">
103
+ <span className="text-muted-foreground">Name</span>
104
+ <span className="font-mono">{metadata.name}</span>
105
+ </div>
106
+ {metadata.namespace && (
107
+ <div className="flex justify-between">
108
+ <span className="text-muted-foreground">Namespace</span>
109
+ <span className="font-mono">{metadata.namespace}</span>
110
+ </div>
111
+ )}
112
+ <div className="flex justify-between">
113
+ <span className="text-muted-foreground">UID</span>
114
+ <span className="font-mono text-xs">{metadata.uid}</span>
115
+ </div>
116
+ <div className="flex justify-between">
117
+ <span className="text-muted-foreground">Age</span>
118
+ <AgeDisplay timestamp={metadata.creationTimestamp} />
119
+ </div>
120
+ </div>
121
+ </div>
122
+
123
+ <div className="space-y-3">
124
+ <h3 className="text-sm font-semibold">Labels</h3>
125
+ <div className="rounded-md border p-3 flex flex-wrap gap-1">
126
+ {Object.entries(labels).length > 0 ? (
127
+ Object.entries(labels).map(([k, v]) => (
128
+ <Badge key={k} variant="secondary" className="text-xs font-mono">
129
+ {k}={v as string}
130
+ </Badge>
131
+ ))
132
+ ) : (
133
+ <span className="text-sm text-muted-foreground">No labels</span>
134
+ )}
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ {Object.entries(annotations).length > 0 && (
140
+ <div className="space-y-3">
141
+ <h3 className="text-sm font-semibold">Annotations</h3>
142
+ <div className="rounded-md border p-3 space-y-1">
143
+ {Object.entries(annotations).map(([k, v]) => (
144
+ <div key={k} className="text-xs">
145
+ <span className="font-mono text-muted-foreground">{k}</span>
146
+ <span className="font-mono ml-2 break-all">{v as string}</span>
147
+ </div>
148
+ ))}
149
+ </div>
150
+ </div>
151
+ )}
152
+ </TabsContent>
153
+
154
+ {children && (
155
+ <TabsContent value="extra" className="mt-4">
156
+ {children}
157
+ </TabsContent>
158
+ )}
159
+
160
+ <TabsContent value="yaml" className="mt-4">
161
+ <YamlEditor
162
+ data={resource}
163
+ apiUrl={yamlApiUrl}
164
+ onSaved={() => mutate()}
165
+ portForwardContext={
166
+ !clusterScoped && (resourceType === 'services' || resourceType === 'pods')
167
+ ? {
168
+ clusterId: decodeURIComponent(clusterId),
169
+ namespace,
170
+ resourceType: resourceType === 'services' ? 'svc' : 'pod',
171
+ resourceName: name,
172
+ }
173
+ : undefined
174
+ }
175
+ />
176
+ </TabsContent>
177
+ </Tabs>
178
+
179
+ <ConfirmDialog
180
+ open={deleteOpen}
181
+ onOpenChange={setDeleteOpen}
182
+ title={`Delete ${metadata.name}?`}
183
+ description={`This will permanently delete the ${resourceType.slice(0, -1)} "${metadata.name}". This action cannot be undone.`}
184
+ confirmLabel="Delete"
185
+ variant="destructive"
186
+ onConfirm={handleDelete}
187
+ loading={deleting}
188
+ />
189
+ </div>
190
+ );
191
+ }
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import { useParams } from 'next/navigation';
4
+ import Link from 'next/link';
5
+ import { useResourceList } from '@/hooks/use-resource-list';
6
+ import { DataTable } from '@/components/shared/data-table';
7
+ import { LoadingSkeleton } from '@/components/shared/loading-skeleton';
8
+ import { ErrorDisplay } from '@/components/shared/error-display';
9
+ import { COLUMN_MAP } from './resource-columns';
10
+ import { RESOURCE_LABELS } from '@/lib/constants';
11
+ import { ColumnDef } from '@tanstack/react-table';
12
+ import { usePodRestartWatcher } from '@/hooks/use-pod-watcher';
13
+
14
+ interface ResourceListPageProps {
15
+ resourceType: string;
16
+ clusterScoped?: boolean;
17
+ }
18
+
19
+ export function ResourceListPage({ resourceType, clusterScoped }: ResourceListPageProps) {
20
+ const params = useParams();
21
+ const clusterId = params.clusterId as string;
22
+ const namespace = (params.namespace as string) || '_';
23
+
24
+ const { data, error, isLoading, mutate } = useResourceList({
25
+ clusterId: clusterId ? decodeURIComponent(clusterId) : null,
26
+ namespace: clusterScoped ? '_' : namespace,
27
+ resourceType,
28
+ });
29
+
30
+ const columns = COLUMN_MAP[resourceType] || [];
31
+ const label = RESOURCE_LABELS[resourceType] || resourceType;
32
+
33
+ // Watch for pod restarts when viewing pods list
34
+ usePodRestartWatcher(
35
+ decodeURIComponent(clusterId),
36
+ resourceType === 'pods' ? data?.items : undefined
37
+ );
38
+
39
+ if (isLoading) return <LoadingSkeleton />;
40
+ if (error) return <ErrorDisplay error={error} onRetry={() => mutate()} clusterId={clusterId} />;
41
+
42
+ const items = data?.items || [];
43
+
44
+ // Make name column clickable - use resource's own namespace for detail link
45
+ const clickableColumns: ColumnDef<any>[] = columns.map((col: any) => {
46
+ if (col.id === 'name') {
47
+ return {
48
+ ...col,
49
+ cell: ({ row }: any) => {
50
+ const name = row.original.metadata?.name;
51
+ const itemNs = row.original.metadata?.namespace || namespace;
52
+ const basePath = clusterScoped
53
+ ? `/clusters/${clusterId}/${resourceType}/${name}`
54
+ : `/clusters/${clusterId}/namespaces/${itemNs}/${resourceType}/${name}`;
55
+ return (
56
+ <Link href={basePath} className="font-medium text-primary hover:underline">
57
+ {name}
58
+ </Link>
59
+ );
60
+ },
61
+ };
62
+ }
63
+ return col;
64
+ });
65
+
66
+ return (
67
+ <div className="flex flex-col gap-4 p-6">
68
+ <div className="flex items-center justify-between">
69
+ <h1 className="text-2xl font-bold">{label}</h1>
70
+ </div>
71
+ <DataTable
72
+ columns={clickableColumns}
73
+ data={items}
74
+ searchPlaceholder={`Search ${label.toLowerCase()}...`}
75
+ />
76
+ </div>
77
+ );
78
+ }