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,570 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import useSWR from 'swr';
5
+ import { Eye, EyeOff, Loader2 } from 'lucide-react';
6
+ import { Badge } from '@/components/ui/badge';
7
+
8
+ // Sensitive key patterns
9
+ const SENSITIVE_PATTERNS = /password|passwd|secret|token|api[_-]?key|apikey|auth|credential|private/i;
10
+
11
+ function isSensitiveKey(name: string): boolean {
12
+ return SENSITIVE_PATTERNS.test(name);
13
+ }
14
+
15
+ function MaskedValue({ value }: { value: string }) {
16
+ const [revealed, setRevealed] = useState(false);
17
+ return (
18
+ <span className="inline-flex items-center gap-1">
19
+ {revealed ? (
20
+ <>
21
+ <span className="font-mono break-all">{value}</span>
22
+ <button onClick={() => setRevealed(false)} className="text-muted-foreground hover:text-foreground p-0.5"><EyeOff className="h-3 w-3" /></button>
23
+ </>
24
+ ) : (
25
+ <>
26
+ <span className="font-mono">{'•'.repeat(Math.min(value.length, 16))}</span>
27
+ <button onClick={() => setRevealed(true)} className="text-muted-foreground hover:text-foreground p-0.5"><Eye className="h-3 w-3" /></button>
28
+ </>
29
+ )}
30
+ </span>
31
+ );
32
+ }
33
+
34
+ function SecretValueInline({ clusterId, namespace, secretName, secretKey }: {
35
+ clusterId: string; namespace: string; secretName: string; secretKey: string;
36
+ }) {
37
+ const { data, error, isLoading } = useSWR(
38
+ `/api/clusters/${encodeURIComponent(clusterId)}/resources/${namespace}/secrets/${secretName}`,
39
+ { revalidateOnFocus: false }
40
+ );
41
+ if (isLoading) return <Loader2 className="h-3 w-3 animate-spin inline" />;
42
+ if (error) return <span className="text-amber-600 dark:text-amber-400">secret:{secretName}/{secretKey}</span>;
43
+
44
+ const rawValue = data?.data?.[secretKey];
45
+ if (!rawValue) return <span className="text-muted-foreground italic">key not found</span>;
46
+
47
+ let decoded: string;
48
+ try { decoded = atob(rawValue); } catch { decoded = rawValue; }
49
+
50
+ return (
51
+ <span className="inline-flex items-center gap-1">
52
+ {isSensitiveKey(secretKey) ? <MaskedValue value={decoded} /> : <span className="font-mono break-all">{decoded}</span>}
53
+ <span className="text-[9px] text-amber-600/60 dark:text-amber-400/60 ml-1">({secretName})</span>
54
+ </span>
55
+ );
56
+ }
57
+
58
+ function ConfigMapValueInline({ clusterId, namespace, configMapName, configMapKey }: {
59
+ clusterId: string; namespace: string; configMapName: string; configMapKey: string;
60
+ }) {
61
+ const { data, error, isLoading } = useSWR(
62
+ `/api/clusters/${encodeURIComponent(clusterId)}/resources/${namespace}/configmaps/${configMapName}`,
63
+ { revalidateOnFocus: false }
64
+ );
65
+ if (isLoading) return <Loader2 className="h-3 w-3 animate-spin inline" />;
66
+ if (error) return <span className="text-cyan-600 dark:text-cyan-400">configmap:{configMapName}/{configMapKey}</span>;
67
+
68
+ const value = data?.data?.[configMapKey];
69
+ if (value === undefined) return <span className="text-muted-foreground italic">key not found</span>;
70
+
71
+ return (
72
+ <span className="inline-flex items-center gap-1">
73
+ {isSensitiveKey(configMapKey) ? <MaskedValue value={value} /> : <span className="font-mono break-all">{value.length > 100 ? value.substring(0, 100) + '...' : value}</span>}
74
+ <span className="text-[9px] text-cyan-600/60 dark:text-cyan-400/60 ml-1">({configMapName})</span>
75
+ </span>
76
+ );
77
+ }
78
+
79
+ // Single env var value cell
80
+ export function EnvValueCell({ env, clusterId, namespace }: {
81
+ env: any; clusterId: string; namespace: string;
82
+ }) {
83
+ if (env.value !== undefined) {
84
+ if (isSensitiveKey(env.name) && env.value) return <MaskedValue value={env.value} />;
85
+ return <span>{env.value || <span className="text-muted-foreground italic">empty</span>}</span>;
86
+ }
87
+ if (env.valueFrom?.secretKeyRef) {
88
+ return <SecretValueInline clusterId={clusterId} namespace={namespace} secretName={env.valueFrom.secretKeyRef.name} secretKey={env.valueFrom.secretKeyRef.key} />;
89
+ }
90
+ if (env.valueFrom?.configMapKeyRef) {
91
+ return <ConfigMapValueInline clusterId={clusterId} namespace={namespace} configMapName={env.valueFrom.configMapKeyRef.name} configMapKey={env.valueFrom.configMapKeyRef.key} />;
92
+ }
93
+ if (env.valueFrom?.fieldRef) return <span className="text-purple-600 dark:text-purple-400">field:{env.valueFrom.fieldRef.fieldPath}</span>;
94
+ if (env.valueFrom?.resourceFieldRef) return <span className="text-purple-600 dark:text-purple-400">resource:{env.valueFrom.resourceFieldRef.resource}</span>;
95
+ return <span className="text-muted-foreground">-</span>;
96
+ }
97
+
98
+ // Resolve envFrom references into individual env var rows
99
+ export function EnvFromRows({ envFrom, clusterId, namespace }: {
100
+ envFrom: any[]; clusterId: string; namespace: string;
101
+ }) {
102
+ return (
103
+ <>
104
+ {envFrom.map((ef: any, efi: number) => (
105
+ <EnvFromBlock key={efi} entry={ef} clusterId={clusterId} namespace={namespace} />
106
+ ))}
107
+ </>
108
+ );
109
+ }
110
+
111
+ // Resolve mounted Secret/ConfigMap volumes into env-like rows
112
+ export function MountedSecretRows({ volumes, volumeMounts, clusterId, namespace }: {
113
+ volumes: any[]; volumeMounts: any[]; clusterId: string; namespace: string;
114
+ }) {
115
+ // Find volume mounts that reference secrets or configmaps
116
+ const mountedSecrets: { volumeName: string; secretName: string; mountPath: string }[] = [];
117
+ const mountedConfigMaps: { volumeName: string; configMapName: string; mountPath: string }[] = [];
118
+ const seen = new Set<string>();
119
+
120
+ for (const vm of volumeMounts || []) {
121
+ const vol = (volumes || []).find((v: any) => v.name === vm.name);
122
+ if (!vol) continue;
123
+ if (vol.secret && !seen.has(`secret:${vol.secret.secretName}`)) {
124
+ seen.add(`secret:${vol.secret.secretName}`);
125
+ mountedSecrets.push({ volumeName: vm.name, secretName: vol.secret.secretName, mountPath: vm.mountPath });
126
+ } else if (vol.configMap && !seen.has(`cm:${vol.configMap.name}`)) {
127
+ seen.add(`cm:${vol.configMap.name}`);
128
+ mountedConfigMaps.push({ volumeName: vm.name, configMapName: vol.configMap.name, mountPath: vm.mountPath });
129
+ } else if (vol.projected) {
130
+ // Handle projected volumes (can contain multiple secrets/configmaps)
131
+ for (const source of vol.projected.sources || []) {
132
+ if (source.secret && !seen.has(`secret:${source.secret.name}`)) {
133
+ seen.add(`secret:${source.secret.name}`);
134
+ mountedSecrets.push({ volumeName: vm.name, secretName: source.secret.name, mountPath: vm.mountPath });
135
+ }
136
+ if (source.configMap && !seen.has(`cm:${source.configMap.name}`)) {
137
+ seen.add(`cm:${source.configMap.name}`);
138
+ mountedConfigMaps.push({ volumeName: vm.name, configMapName: source.configMap.name, mountPath: vm.mountPath });
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ if (mountedSecrets.length === 0 && mountedConfigMaps.length === 0) return null;
145
+
146
+ return (
147
+ <>
148
+ {mountedSecrets.map((ms) => (
149
+ <MountedResourceBlock
150
+ key={ms.secretName}
151
+ clusterId={clusterId}
152
+ namespace={namespace}
153
+ resourceType="secrets"
154
+ resourceName={ms.secretName}
155
+ mountPath={ms.mountPath}
156
+ isSecret={true}
157
+ />
158
+ ))}
159
+ {mountedConfigMaps.map((mc) => (
160
+ <MountedResourceBlock
161
+ key={mc.configMapName}
162
+ clusterId={clusterId}
163
+ namespace={namespace}
164
+ resourceType="configmaps"
165
+ resourceName={mc.configMapName}
166
+ mountPath={mc.mountPath}
167
+ isSecret={false}
168
+ />
169
+ ))}
170
+ </>
171
+ );
172
+ }
173
+
174
+ function MountedResourceBlock({ clusterId, namespace, resourceType, resourceName, mountPath, isSecret }: {
175
+ clusterId: string; namespace: string; resourceType: string; resourceName: string; mountPath: string; isSecret: boolean;
176
+ }) {
177
+ const { data, error, isLoading } = useSWR(
178
+ `/api/clusters/${encodeURIComponent(clusterId)}/resources/${namespace}/${resourceType}/${resourceName}`,
179
+ { revalidateOnFocus: false }
180
+ );
181
+
182
+ if (isLoading) {
183
+ return (
184
+ <tr className="border-t">
185
+ <td colSpan={2} className="px-3 py-1 text-muted-foreground">
186
+ <Loader2 className="h-3 w-3 animate-spin inline mr-1" />
187
+ Loading {resourceType}/{resourceName}...
188
+ </td>
189
+ </tr>
190
+ );
191
+ }
192
+
193
+ if (error) {
194
+ return (
195
+ <tr className="border-t">
196
+ <td colSpan={2} className="px-3 py-1">
197
+ <span className={isSecret ? 'text-amber-600 dark:text-amber-400' : 'text-cyan-600 dark:text-cyan-400'}>
198
+ mounted: {resourceType}/{resourceName} (access denied)
199
+ </span>
200
+ </td>
201
+ </tr>
202
+ );
203
+ }
204
+
205
+ // Support both data and stringData fields
206
+ const rawData = data?.data || {};
207
+ const stringData = data?.stringData || {};
208
+ const mergedData = { ...rawData, ...stringData };
209
+ const entries = Object.entries(mergedData).sort(([a], [b]) => a.localeCompare(b));
210
+
211
+ if (entries.length === 0) return null;
212
+
213
+ // Check if a single key contains dotenv-style content (KEY=VALUE lines)
214
+ const parsedEntries: [string, string][] = [];
215
+ for (const [key, rawValue] of entries) {
216
+ let value = rawValue as string;
217
+ if (isSecret && data?.data?.[key]) {
218
+ // Only decode base64 for items from data (not stringData)
219
+ try { value = atob(value); } catch { /* keep raw */ }
220
+ }
221
+ // Try to parse as dotenv (.env file format)
222
+ if (value.includes('=') && value.includes('\n')) {
223
+ const lines = value.split('\n');
224
+ for (const line of lines) {
225
+ const trimmed = line.trim();
226
+ if (!trimmed || trimmed.startsWith('#')) continue;
227
+ const eqIdx = trimmed.indexOf('=');
228
+ if (eqIdx > 0) {
229
+ const envKey = trimmed.substring(0, eqIdx).trim();
230
+ let envVal = trimmed.substring(eqIdx + 1).trim();
231
+ // Remove surrounding quotes
232
+ if ((envVal.startsWith('"') && envVal.endsWith('"')) || (envVal.startsWith("'") && envVal.endsWith("'"))) {
233
+ envVal = envVal.slice(1, -1);
234
+ }
235
+ parsedEntries.push([envKey, envVal]);
236
+ }
237
+ }
238
+ } else {
239
+ parsedEntries.push([key, value]);
240
+ }
241
+ }
242
+
243
+ return (
244
+ <>
245
+ <tr className="border-t bg-muted/30">
246
+ <td colSpan={2} className="px-3 py-1">
247
+ <Badge variant={isSecret ? 'default' : 'secondary'} className="text-[9px] py-0 h-4 font-normal">
248
+ Mounted {isSecret ? 'Secret' : 'ConfigMap'}: {resourceName}
249
+ </Badge>
250
+ <span className="text-[10px] text-muted-foreground ml-1.5">{mountPath}</span>
251
+ </td>
252
+ </tr>
253
+ {parsedEntries.map(([key, value], idx) => {
254
+ const sensitive = isSensitiveKey(key);
255
+ return (
256
+ <tr key={`${resourceName}:${idx}`} className="border-t hover:bg-muted/30">
257
+ <td className="px-3 py-1 font-mono font-medium text-blue-600 dark:text-blue-400 truncate">{key}</td>
258
+ <td className="px-3 py-1 font-mono overflow-hidden">
259
+ <div className="break-all">
260
+ {sensitive && value ? (
261
+ <MaskedValue value={value} />
262
+ ) : (
263
+ value
264
+ )}
265
+ </div>
266
+ </td>
267
+ </tr>
268
+ );
269
+ })}
270
+ </>
271
+ );
272
+ }
273
+
274
+ // Pod-level: extract ALL secret/configmap names from entire pod spec and show all keys
275
+ export function PodLinkedSecrets({ podSpec, clusterId, namespace }: {
276
+ podSpec: any; clusterId: string; namespace: string;
277
+ }) {
278
+ // Gather every unique secret & configmap referenced in the entire pod spec
279
+ const secretNames = new Set<string>();
280
+ const configMapNames = new Set<string>();
281
+
282
+ const allContainers = [
283
+ ...(podSpec.containers || []),
284
+ ...(podSpec.initContainers || []),
285
+ ...(podSpec.ephemeralContainers || []),
286
+ ];
287
+
288
+ for (const ctr of allContainers) {
289
+ // env[].valueFrom.secretKeyRef / configMapKeyRef
290
+ for (const env of ctr.env || []) {
291
+ if (env.valueFrom?.secretKeyRef?.name) secretNames.add(env.valueFrom.secretKeyRef.name);
292
+ if (env.valueFrom?.configMapKeyRef?.name) configMapNames.add(env.valueFrom.configMapKeyRef.name);
293
+ }
294
+ // envFrom[].secretRef / configMapRef
295
+ for (const ef of ctr.envFrom || []) {
296
+ if (ef.secretRef?.name) secretNames.add(ef.secretRef.name);
297
+ if (ef.configMapRef?.name) configMapNames.add(ef.configMapRef.name);
298
+ }
299
+ }
300
+
301
+ // volumes
302
+ for (const vol of podSpec.volumes || []) {
303
+ if (vol.secret?.secretName) secretNames.add(vol.secret.secretName);
304
+ if (vol.configMap?.name) configMapNames.add(vol.configMap.name);
305
+ if (vol.projected) {
306
+ for (const src of vol.projected.sources || []) {
307
+ if (src.secret?.name) secretNames.add(src.secret.name);
308
+ if (src.configMap?.name) configMapNames.add(src.configMap.name);
309
+ }
310
+ }
311
+ if (vol.csi?.volumeAttributes?.secretProviderClass) {
312
+ // CSI secrets-store volumes: we can't directly resolve these
313
+ }
314
+ }
315
+
316
+ // imagePullSecrets
317
+ for (const ips of podSpec.imagePullSecrets || []) {
318
+ if (ips.name) secretNames.add(ips.name);
319
+ }
320
+
321
+ const secrets = Array.from(secretNames).sort();
322
+ const configMaps = Array.from(configMapNames).sort();
323
+
324
+ if (secrets.length === 0 && configMaps.length === 0) return null;
325
+
326
+ return (
327
+ <div className="space-y-3">
328
+ <h3 className="text-sm font-semibold">Linked Secrets & ConfigMaps</h3>
329
+ <div className="rounded-md border overflow-hidden">
330
+ <table className="w-full text-xs" style={{ tableLayout: 'fixed' }}>
331
+ <colgroup>
332
+ <col style={{ width: '35%' }} />
333
+ <col style={{ width: '65%' }} />
334
+ </colgroup>
335
+ <thead className="bg-muted/50 sticky top-0 z-10">
336
+ <tr>
337
+ <th className="px-3 py-1.5 text-left font-medium">Key</th>
338
+ <th className="px-3 py-1.5 text-left font-medium">Value</th>
339
+ </tr>
340
+ </thead>
341
+ <tbody>
342
+ {secrets.map((name) => (
343
+ <LinkedResourceBlock
344
+ key={`s:${name}`}
345
+ clusterId={clusterId}
346
+ namespace={namespace}
347
+ resourceType="secrets"
348
+ resourceName={name}
349
+ isSecret={true}
350
+ />
351
+ ))}
352
+ {configMaps.map((name) => (
353
+ <LinkedResourceBlock
354
+ key={`cm:${name}`}
355
+ clusterId={clusterId}
356
+ namespace={namespace}
357
+ resourceType="configmaps"
358
+ resourceName={name}
359
+ isSecret={false}
360
+ />
361
+ ))}
362
+ </tbody>
363
+ </table>
364
+ </div>
365
+ </div>
366
+ );
367
+ }
368
+
369
+ function LinkedResourceBlock({ clusterId, namespace, resourceType, resourceName, isSecret }: {
370
+ clusterId: string; namespace: string; resourceType: string; resourceName: string; isSecret: boolean;
371
+ }) {
372
+ const { data, error, isLoading } = useSWR(
373
+ `/api/clusters/${encodeURIComponent(clusterId)}/resources/${namespace}/${resourceType}/${resourceName}`,
374
+ { revalidateOnFocus: false }
375
+ );
376
+
377
+ if (isLoading) {
378
+ return (
379
+ <tr className="border-t">
380
+ <td colSpan={2} className="px-3 py-1 text-muted-foreground">
381
+ <Loader2 className="h-3 w-3 animate-spin inline mr-1" />
382
+ {resourceName}...
383
+ </td>
384
+ </tr>
385
+ );
386
+ }
387
+
388
+ if (error) {
389
+ return (
390
+ <tr className="border-t">
391
+ <td colSpan={2} className="px-3 py-1">
392
+ <Badge variant={isSecret ? 'default' : 'secondary'} className="text-[9px] py-0 h-4 font-normal mr-1">
393
+ {isSecret ? 'Secret' : 'ConfigMap'}
394
+ </Badge>
395
+ <span className={isSecret ? 'text-amber-600 dark:text-amber-400' : 'text-cyan-600 dark:text-cyan-400'}>
396
+ {resourceName} <span className="text-muted-foreground">(access denied)</span>
397
+ </span>
398
+ </td>
399
+ </tr>
400
+ );
401
+ }
402
+
403
+ const rawData = data?.data || {};
404
+ const stringData = data?.stringData || {};
405
+ const mergedData = { ...rawData, ...stringData };
406
+ const entries = Object.entries(mergedData).sort(([a], [b]) => a.localeCompare(b));
407
+
408
+ if (entries.length === 0) {
409
+ return (
410
+ <tr className="border-t">
411
+ <td colSpan={2} className="px-3 py-1 text-muted-foreground italic">
412
+ <Badge variant={isSecret ? 'default' : 'secondary'} className="text-[9px] py-0 h-4 font-normal mr-1">
413
+ {isSecret ? 'Secret' : 'ConfigMap'}
414
+ </Badge>
415
+ {resourceName} (empty)
416
+ </td>
417
+ </tr>
418
+ );
419
+ }
420
+
421
+ // Parse entries, decode base64 for secrets, expand dotenv format
422
+ const parsedEntries: [string, string][] = [];
423
+ for (const [key, rawValue] of entries) {
424
+ let value = rawValue as string;
425
+ if (isSecret && rawData[key]) {
426
+ try { value = atob(value); } catch { /* keep raw */ }
427
+ }
428
+ // Try to parse as dotenv (.env file format)
429
+ if (value.includes('=') && value.includes('\n')) {
430
+ const lines = value.split('\n');
431
+ for (const line of lines) {
432
+ const trimmed = line.trim();
433
+ if (!trimmed || trimmed.startsWith('#')) continue;
434
+ const eqIdx = trimmed.indexOf('=');
435
+ if (eqIdx > 0) {
436
+ const envKey = trimmed.substring(0, eqIdx).trim();
437
+ let envVal = trimmed.substring(eqIdx + 1).trim();
438
+ if ((envVal.startsWith('"') && envVal.endsWith('"')) || (envVal.startsWith("'") && envVal.endsWith("'"))) {
439
+ envVal = envVal.slice(1, -1);
440
+ }
441
+ parsedEntries.push([envKey, envVal]);
442
+ }
443
+ }
444
+ } else {
445
+ parsedEntries.push([key, value]);
446
+ }
447
+ }
448
+
449
+ return (
450
+ <>
451
+ <tr className="border-t bg-muted/30">
452
+ <td colSpan={2} className="px-3 py-1">
453
+ <Badge variant={isSecret ? 'default' : 'secondary'} className="text-[9px] py-0 h-4 font-normal">
454
+ {isSecret ? 'Secret' : 'ConfigMap'}: {resourceName}
455
+ </Badge>
456
+ <span className="text-[10px] text-muted-foreground ml-1.5">{parsedEntries.length} keys</span>
457
+ </td>
458
+ </tr>
459
+ {parsedEntries.map(([key, value], pi) => {
460
+ const sensitive = isSensitiveKey(key);
461
+ return (
462
+ <tr key={`linked:${resourceName}:${pi}`} className="border-t hover:bg-muted/30">
463
+ <td className="px-3 py-1 font-mono font-medium text-blue-600 dark:text-blue-400 truncate">{key}</td>
464
+ <td className="px-3 py-1 font-mono overflow-hidden">
465
+ <div className="break-all">
466
+ {sensitive && value ? (
467
+ <MaskedValue value={value} />
468
+ ) : (
469
+ value
470
+ )}
471
+ </div>
472
+ </td>
473
+ </tr>
474
+ );
475
+ })}
476
+ </>
477
+ );
478
+ }
479
+
480
+ function EnvFromBlock({ entry, clusterId, namespace }: {
481
+ entry: any; clusterId: string; namespace: string;
482
+ }) {
483
+ const isSecret = !!entry.secretRef;
484
+ const resourceName = entry.secretRef?.name || entry.configMapRef?.name;
485
+ const prefix = entry.prefix || '';
486
+ const resourceType = isSecret ? 'secrets' : 'configmaps';
487
+
488
+ const { data, error, isLoading } = useSWR(
489
+ resourceName ? `/api/clusters/${encodeURIComponent(clusterId)}/resources/${namespace}/${resourceType}/${resourceName}` : null,
490
+ { revalidateOnFocus: false }
491
+ );
492
+
493
+ if (!resourceName) return null;
494
+
495
+ if (isLoading) {
496
+ return (
497
+ <tr className="border-t">
498
+ <td colSpan={2} className="px-3 py-1 text-muted-foreground">
499
+ <Loader2 className="h-3 w-3 animate-spin inline mr-1" />
500
+ Loading {resourceType}/{resourceName}...
501
+ </td>
502
+ </tr>
503
+ );
504
+ }
505
+
506
+ if (error) {
507
+ return (
508
+ <tr className="border-t">
509
+ <td colSpan={2} className="px-3 py-1">
510
+ <span className={isSecret ? 'text-amber-600 dark:text-amber-400' : 'text-cyan-600 dark:text-cyan-400'}>
511
+ envFrom: {resourceType}/{resourceName} (access denied)
512
+ </span>
513
+ </td>
514
+ </tr>
515
+ );
516
+ }
517
+
518
+ const rawData = data?.data || {};
519
+ const entries = Object.entries(rawData).sort(([a], [b]) => a.localeCompare(b));
520
+
521
+ if (entries.length === 0) {
522
+ return (
523
+ <tr className="border-t">
524
+ <td colSpan={2} className="px-3 py-1 text-muted-foreground italic">
525
+ envFrom: {resourceType}/{resourceName} (empty)
526
+ </td>
527
+ </tr>
528
+ );
529
+ }
530
+
531
+ return (
532
+ <>
533
+ {/* Section header */}
534
+ <tr className="border-t bg-muted/30">
535
+ <td colSpan={2} className="px-3 py-1">
536
+ <Badge variant={isSecret ? 'default' : 'secondary'} className="text-[9px] py-0 h-4 font-normal">
537
+ {isSecret ? 'Secret' : 'ConfigMap'}: {resourceName}
538
+ </Badge>
539
+ {prefix && <span className="text-[10px] text-muted-foreground ml-1.5">prefix: {prefix}</span>}
540
+ </td>
541
+ </tr>
542
+ {entries.map(([key, rawValue], idx) => {
543
+ const envName = `${prefix}${key}`;
544
+ let displayValue = rawValue as string;
545
+
546
+ // Decode base64 for secrets
547
+ if (isSecret) {
548
+ try { displayValue = atob(displayValue); } catch { /* keep raw */ }
549
+ }
550
+
551
+ const sensitive = isSensitiveKey(envName) || isSensitiveKey(key);
552
+
553
+ return (
554
+ <tr key={`${resourceName}:${idx}`} className="border-t hover:bg-muted/30">
555
+ <td className="px-3 py-1 font-mono font-medium text-blue-600 dark:text-blue-400 truncate">{envName}</td>
556
+ <td className="px-3 py-1 font-mono overflow-hidden">
557
+ <div className="break-all">
558
+ {sensitive && displayValue ? (
559
+ <MaskedValue value={displayValue} />
560
+ ) : (
561
+ displayValue
562
+ )}
563
+ </div>
564
+ </td>
565
+ </tr>
566
+ );
567
+ })}
568
+ </>
569
+ );
570
+ }
@@ -0,0 +1,109 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { AlertTriangle, RefreshCw, LogIn, Loader2 } from 'lucide-react';
5
+ import { Button } from '@/components/ui/button';
6
+ import { useSettingsStore } from '@/stores/settings-store';
7
+ import { apiClient } from '@/lib/api-client';
8
+ import { toast } from 'sonner';
9
+
10
+ interface ErrorDisplayProps {
11
+ error: Error & { status?: number };
12
+ onRetry?: () => void;
13
+ clusterId?: string;
14
+ }
15
+
16
+ export function ErrorDisplay({ error, onRetry, clusterId }: ErrorDisplayProps) {
17
+ const { tshProxyUrl, tshAuthType } = useSettingsStore();
18
+ const [tshLoading, setTshLoading] = useState(false);
19
+ const [kubeLoading, setKubeLoading] = useState(false);
20
+
21
+ const isTeleportAuth = error.message?.includes('credentials') ||
22
+ error.message?.includes('certificate') ||
23
+ error.message?.includes('unauthorized') ||
24
+ error.status === 401;
25
+
26
+ const tshConfigured = !!tshProxyUrl;
27
+
28
+ const handleTshLogin = async () => {
29
+ setTshLoading(true);
30
+ try {
31
+ await apiClient.post('/api/tsh/login', {
32
+ action: 'proxy-login',
33
+ proxyUrl: tshProxyUrl,
34
+ authType: tshAuthType || undefined,
35
+ });
36
+ toast.success('TSH login successful');
37
+ onRetry?.();
38
+ } catch (err: unknown) {
39
+ toast.error(`TSH login failed: ${(err as Error).message}`);
40
+ } finally {
41
+ setTshLoading(false);
42
+ }
43
+ };
44
+
45
+ const handleKubeLogin = async () => {
46
+ if (!clusterId) return;
47
+ setKubeLoading(true);
48
+ try {
49
+ await apiClient.post('/api/tsh/login', {
50
+ action: 'kube-login',
51
+ cluster: decodeURIComponent(clusterId),
52
+ });
53
+ toast.success('Kube login successful');
54
+ onRetry?.();
55
+ } catch (err: unknown) {
56
+ toast.error(`Kube login failed: ${(err as Error).message}`);
57
+ } finally {
58
+ setKubeLoading(false);
59
+ }
60
+ };
61
+
62
+ return (
63
+ <div className="flex flex-col items-center justify-center gap-4 p-12 text-center">
64
+ <AlertTriangle className="h-12 w-12 text-destructive" />
65
+ <div>
66
+ <h3 className="text-lg font-semibold">
67
+ {isTeleportAuth ? 'Authentication Required' : 'Error'}
68
+ </h3>
69
+ <p className="mt-1 text-sm text-muted-foreground">{error.message}</p>
70
+ {isTeleportAuth && (
71
+ <p className="mt-2 rounded-md bg-muted p-3 font-mono text-sm">
72
+ tsh kube login &lt;cluster-name&gt;
73
+ </p>
74
+ )}
75
+ </div>
76
+ {isTeleportAuth && (
77
+ <div className="flex gap-2">
78
+ <Button
79
+ variant="outline"
80
+ onClick={handleTshLogin}
81
+ disabled={!tshConfigured || tshLoading}
82
+ className="gap-2"
83
+ title={!tshConfigured ? 'Configure TSH proxy in Settings first' : undefined}
84
+ >
85
+ {tshLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogIn className="h-4 w-4" />}
86
+ TSH Login
87
+ </Button>
88
+ {clusterId && (
89
+ <Button
90
+ variant="outline"
91
+ onClick={handleKubeLogin}
92
+ disabled={kubeLoading}
93
+ className="gap-2"
94
+ >
95
+ {kubeLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogIn className="h-4 w-4" />}
96
+ Kube Login
97
+ </Button>
98
+ )}
99
+ </div>
100
+ )}
101
+ {onRetry && (
102
+ <Button variant="outline" onClick={onRetry} className="gap-2">
103
+ <RefreshCw className="h-4 w-4" />
104
+ Retry
105
+ </Button>
106
+ )}
107
+ </div>
108
+ );
109
+ }