openk8s 1.0.1 → 1.0.2

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 (47) hide show
  1. package/package.json +5 -2
  2. package/src/app/__tests__/app-state.test.ts +376 -0
  3. package/src/app/__tests__/components/inspector-tokens.test.ts +101 -0
  4. package/src/app/__tests__/utils.test.ts +358 -0
  5. package/src/app/app-actions.ts +262 -0
  6. package/src/app/app-state.ts +4 -262
  7. package/src/app/app.tsx +22 -170
  8. package/src/app/components/detail-sections.tsx +131 -0
  9. package/src/app/components/footer.tsx +52 -0
  10. package/src/app/components/header.tsx +37 -0
  11. package/src/app/components/inspector-tokens.ts +93 -0
  12. package/src/app/components/inspector.tsx +3 -239
  13. package/src/app/components/resource-rows.tsx +5 -0
  14. package/src/app/hooks/keyboard/filter-handlers.ts +40 -0
  15. package/src/app/hooks/keyboard/global-handlers.ts +134 -0
  16. package/src/app/hooks/keyboard/helm-handlers.ts +104 -0
  17. package/src/app/hooks/keyboard/logs-handlers.ts +80 -0
  18. package/src/app/hooks/keyboard/navigation-handlers.ts +103 -0
  19. package/src/app/hooks/keyboard/overlay-handlers.ts +138 -0
  20. package/src/app/hooks/keyboard/port-forward-handlers.ts +71 -0
  21. package/src/app/hooks/keyboard/shell-edit-handlers.ts +253 -0
  22. package/src/app/hooks/use-app-keyboard.ts +56 -621
  23. package/src/app/hooks/use-app-side-effects.ts +1 -1
  24. package/src/app/hooks/use-clipboard.ts +1 -1
  25. package/src/app/hooks/use-data-fetching.ts +2 -9
  26. package/src/app/hooks/use-log-stream.ts +1 -1
  27. package/src/app/hooks/use-port-forward.ts +1 -1
  28. package/src/app/use-footer-hints.ts +107 -0
  29. package/src/index.tsx +4 -0
  30. package/src/lib/k8s/__tests__/k8s-format.test.ts +42 -0
  31. package/src/lib/k8s/__tests__/resource-detail-builder.test.ts +215 -0
  32. package/src/lib/k8s/__tests__/resource-parser.test.ts +455 -0
  33. package/src/lib/k8s/detail-builders/event-builder.ts +21 -0
  34. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +63 -0
  35. package/src/lib/k8s/detail-builders/node-builder.ts +41 -0
  36. package/src/lib/k8s/detail-builders/overview-builder.ts +103 -0
  37. package/src/lib/k8s/detail-builders/pod-builder.ts +140 -0
  38. package/src/lib/k8s/detail-builders/rbac-builder.ts +57 -0
  39. package/src/lib/k8s/resource-detail-builder.ts +22 -502
  40. package/src/lib/kubectl/__tests__/kubectl-helpers.test.ts +343 -0
  41. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +84 -0
  42. package/src/lib/kubectl/__tests__/spawn-utils.test.ts +56 -0
  43. package/src/lib/kubectl/kubectl-helpers.ts +246 -0
  44. package/src/lib/kubectl/kubectl-service.ts +77 -565
  45. package/src/lib/kubectl/kubectl-types.ts +248 -0
  46. package/src/lib/kubectl/metrics-utils.ts +33 -0
  47. package/src/lib/kubectl/spawn-utils.ts +27 -0
@@ -1,66 +1,44 @@
1
- import type {
2
- ResourceDetail,
3
- ResourceDetailLine,
4
- ResourceDetailSection,
5
- ResourceKind,
6
- ResourcePortForward,
7
- } from "./types";
8
-
9
- import {
10
- asArray,
11
- asBoolean,
12
- asNumber,
13
- asPort,
14
- asRecord,
15
- asString,
16
- buildEnvLine,
17
- extractContainerNames,
18
- extractContainerPorts,
19
- flattenSections,
20
- formatContainerState,
21
- formatEnvFromSource,
22
- formatProbe,
23
- formatValueFrom,
24
- formatVolumeSource,
25
- isPodKind,
26
- normalizeKind,
27
- suggestedLocalPort,
28
- summarizeIngressHosts,
29
- summarizeLabels,
30
- supportsDirectPortForward,
31
- type KubernetesMetadata,
32
- type KubernetesResource,
33
- } from "./resource-parser";
1
+ import type { ResourceDetail, ResourceKind } from "./types";
2
+ import { isPodKind } from "./resource-parser";
3
+ import type { KubernetesResource, KubernetesMetadata } from "./resource-parser";
34
4
 
35
5
  export { type KubernetesMetadata, type KubernetesResource } from "./resource-parser";
36
6
 
7
+ import { buildOverviewSection, buildPortForward } from "./detail-builders/overview-builder";
8
+ import { buildPodSections } from "./detail-builders/pod-builder";
9
+ import { buildHpaSections, buildCronJobSections } from "./detail-builders/hpa-cronjob-builder";
10
+ import { buildNodeSections } from "./detail-builders/node-builder";
11
+ import { buildEventSections } from "./detail-builders/event-builder";
12
+ import { buildRoleSections, buildRoleBindingSections, buildServiceAccountSections } from "./detail-builders/rbac-builder";
13
+ import { flattenSections, extractContainerNames } from "./resource-parser";
14
+
37
15
  export function buildResourceDetail(options: BuildResourceDetailOptions): ResourceDetail {
38
16
  const kindName = options.resource.kind ?? options.resourceKind?.name ?? "unknown";
39
17
  const normalizedKind = kindName.trim().toLowerCase();
40
- const overview = buildOverviewSection({ resource: options.resource, resourceKind: options.resourceKind });
18
+ const overview = buildOverviewSection(options.resourceKind, options.resource);
41
19
 
42
- let additionalSections: ResourceDetailSection[] = [];
20
+ let additionalSections: ReturnType<typeof buildPodSections> = [];
43
21
 
44
22
  if (isPodKind(kindName)) {
45
- additionalSections = buildPodSections({ resource: options.resource });
23
+ additionalSections = buildPodSections(options.resource);
46
24
  } else if (normalizedKind === "horizontalpodautoscaler") {
47
- additionalSections = buildHpaSections({ resource: options.resource });
25
+ additionalSections = buildHpaSections(options.resource);
48
26
  } else if (normalizedKind === "cronjob") {
49
- additionalSections = buildCronJobSections({ resource: options.resource });
27
+ additionalSections = buildCronJobSections(options.resource);
50
28
  } else if (normalizedKind === "node") {
51
- additionalSections = buildNodeSections({ resource: options.resource });
29
+ additionalSections = buildNodeSections(options.resource);
52
30
  } else if (normalizedKind === "event" || normalizedKind === "events") {
53
- additionalSections = buildEventSections({ resource: options.resource });
31
+ additionalSections = buildEventSections(options.resource);
54
32
  } else if (normalizedKind === "role" || normalizedKind === "clusterrole") {
55
- additionalSections = buildRoleSections({ resource: options.resource });
33
+ additionalSections = buildRoleSections(options.resource);
56
34
  } else if (normalizedKind === "rolebinding" || normalizedKind === "clusterrolebinding") {
57
- additionalSections = buildRoleBindingSections({ resource: options.resource });
35
+ additionalSections = buildRoleBindingSections(options.resource);
58
36
  } else if (normalizedKind === "serviceaccount") {
59
- additionalSections = buildServiceAccountSections({ resource: options.resource });
37
+ additionalSections = buildServiceAccountSections(options.resource);
60
38
  }
61
39
 
62
40
  const summarySections = [overview.section, ...additionalSections];
63
- const portForward = buildPortForward({ resource: options.resource, resourceKind: options.resourceKind });
41
+ const portForward = buildPortForward(options.resourceKind, options.resource);
64
42
  const containers = isPodKind(kindName) ? extractContainerNames(options.resource.spec) : undefined;
65
43
 
66
44
  return {
@@ -85,461 +63,3 @@ export interface BuildResourceDetailOptions {
85
63
  yaml: string;
86
64
  describe?: string | undefined;
87
65
  }
88
-
89
- interface BuildOverviewSectionOptions {
90
- resourceKind?: ResourceKind | undefined;
91
- resource: KubernetesResource;
92
- }
93
-
94
- interface BuildPodSectionsOptions {
95
- resource: KubernetesResource;
96
- }
97
-
98
- interface BuildPortForwardOptions {
99
- resourceKind?: ResourceKind | undefined;
100
- resource: KubernetesResource;
101
- }
102
-
103
- function buildPortForward(options: BuildPortForwardOptions): ResourcePortForward[] {
104
- const kindName = options.resource.kind ?? options.resourceKind?.name ?? "unknown";
105
-
106
- if (!supportsDirectPortForward(kindName)) {
107
- return [];
108
- }
109
-
110
- const spec = options.resource.spec ?? {};
111
- let remotePorts: number[];
112
-
113
- if (["service", "services"].includes(normalizeKind(kindName))) {
114
- remotePorts = asArray(spec.ports)
115
- .map((port) => asPort(asRecord(port)?.port))
116
- .filter((port): port is number => port !== undefined);
117
- } else if (isPodKind(kindName)) {
118
- remotePorts = extractContainerPorts(spec);
119
- } else {
120
- remotePorts = extractContainerPorts(asRecord(spec.template)?.spec);
121
- }
122
-
123
- return remotePorts.map((remotePort) => ({
124
- localPort: suggestedLocalPort(remotePort),
125
- remotePort,
126
- }));
127
- }
128
-
129
- function buildOverviewSection(options: BuildOverviewSectionOptions): { section: ResourceDetailSection; replicas?: number | undefined } {
130
- const metadata = options.resource.metadata ?? {};
131
- const status = options.resource.status ?? {};
132
- const spec = options.resource.spec ?? {};
133
-
134
- const lines = [
135
- `kind: ${options.resource.kind ?? options.resourceKind?.name ?? "unknown"}`,
136
- `apiVersion: ${options.resource.apiVersion ?? "unknown"}`,
137
- `name: ${metadata.name ?? "unknown"}`,
138
- `namespace: ${metadata.namespace ?? (options.resourceKind?.namespaced ? "default" : "cluster")}`,
139
- `created: ${metadata.creationTimestamp ?? "unknown"}`,
140
- `labels: ${summarizeLabels(metadata.labels)}`,
141
- ];
142
-
143
- const replicas = asNumber(spec.replicas);
144
- if (replicas !== undefined) {
145
- lines.push(`replicas: ${replicas}`);
146
- }
147
-
148
- const readyReplicas = asNumber(status.readyReplicas);
149
- if (readyReplicas !== undefined) {
150
- lines.push(`readyReplicas: ${readyReplicas}`);
151
- }
152
-
153
- const phase = asString(status.phase);
154
- if (phase) {
155
- lines.push(`phase: ${phase}`);
156
- }
157
-
158
- const qosClass = asString(status.qosClass);
159
- if (qosClass) {
160
- lines.push(`qos: ${qosClass}`);
161
- }
162
-
163
- const clusterIP = asString(spec.clusterIP);
164
- if (clusterIP) {
165
- lines.push(`clusterIP: ${clusterIP}`);
166
- }
167
-
168
- const nodeName = asString(spec.nodeName);
169
- if (nodeName) {
170
- lines.push(`node: ${nodeName}`);
171
- }
172
-
173
- const podIP = asString(status.podIP);
174
- if (podIP) {
175
- lines.push(`podIP: ${podIP}`);
176
- }
177
-
178
- const hostIP = asString(status.hostIP);
179
- if (hostIP) {
180
- lines.push(`hostIP: ${hostIP}`);
181
- }
182
-
183
- const serviceAccount = asString(spec.serviceAccountName);
184
- if (serviceAccount) {
185
- lines.push(`serviceAccount: ${serviceAccount}`);
186
- }
187
-
188
- const completions = asNumber(status.succeeded);
189
- if (completions !== undefined) {
190
- lines.push(`succeeded: ${completions}`);
191
- }
192
-
193
- const ingressHosts = summarizeIngressHosts(spec);
194
- if (ingressHosts) {
195
- lines.push(`hosts: ${ingressHosts}`);
196
- }
197
-
198
- return {
199
- section: { title: "Overview", lines },
200
- replicas,
201
- };
202
- }
203
-
204
- function buildPodSections(options: BuildPodSectionsOptions): ResourceDetailSection[] {
205
- const spec = options.resource.spec ?? {};
206
- const status = options.resource.status ?? {};
207
-
208
- const containerStatuses = new Map(
209
- asArray(status.containerStatuses)
210
- .map((entry) => asRecord(entry))
211
- .filter((entry): entry is Record<string, unknown> => Boolean(entry && asString(entry.name)))
212
- .map((entry) => [asString(entry.name) ?? "", entry]),
213
- );
214
-
215
- const containers = asArray(spec.containers)
216
- .map((entry) => asRecord(entry))
217
- .filter((entry): entry is Record<string, unknown> => entry !== undefined);
218
-
219
- const containerLines = containers.map((container) => {
220
- const name = asString(container.name) ?? "container";
221
- const statusEntry = containerStatuses.get(name);
222
- const ready = asBoolean(statusEntry?.ready);
223
- const restartCount = asNumber(statusEntry?.restartCount);
224
-
225
- return `${name} image ${asString(container.image) ?? "-"} ready ${ready === undefined ? "-" : ready ? "yes" : "no"} restarts ${restartCount ?? 0} state ${formatContainerState(statusEntry?.state)}`;
226
- });
227
-
228
- const environmentLines = containers.flatMap((container) => {
229
- const containerName = asString(container.name) ?? "container";
230
- const envLines = asArray(container.env)
231
- .map((entry) => asRecord(entry))
232
- .filter((entry): entry is Record<string, unknown> => entry !== undefined)
233
- .map((entry, index) => {
234
- const name = asString(entry.name) ?? "ENV";
235
- const value = asString(entry.value);
236
- const valueFrom = asRecord(entry.valueFrom);
237
- const secretKeyRef = asRecord(valueFrom?.secretKeyRef);
238
-
239
- if (secretKeyRef) {
240
- const secretName = asString(secretKeyRef.name) ?? "unknown";
241
- const secretKey = asString(secretKeyRef.key) ?? "key";
242
- const secretRefText = `secret:${secretName}/${secretKey}`;
243
- return buildEnvLine(
244
- `${containerName}:env:${name}:${index}`,
245
- `${name}=*****`,
246
- `${name}=${secretRefText}`,
247
- { name: secretName, key: secretKey },
248
- );
249
- }
250
-
251
- return buildEnvLine(`${containerName}:env:${name}:${index}`, `${name}=${value ?? formatValueFrom(entry.valueFrom)}`);
252
- });
253
-
254
- const envFromLines = asArray(container.envFrom)
255
- .map((entry) => asRecord(entry))
256
- .filter((entry): entry is Record<string, unknown> => entry !== undefined)
257
- .map((entry, index) => {
258
- const secretRef = asRecord(entry.secretRef);
259
-
260
- if (secretRef) {
261
- const secretName = asString(secretRef.name) ?? "unknown";
262
- // envFrom imports ALL keys from the referenced Secret as individual env vars
263
- return buildEnvLine(
264
- `${containerName}:envfrom:secret:${index}`,
265
- `envFrom: secret/${secretName} → *****`,
266
- `envFrom: secret/${secretName} (all keys imported)`,
267
- { name: secretName },
268
- );
269
- }
270
-
271
- return buildEnvLine(`${containerName}:envfrom:${index}`, `envFrom: ${formatEnvFromSource(entry)}`);
272
- });
273
-
274
- return [...envLines, ...envFromLines];
275
- });
276
-
277
- const portLines = containers.flatMap((container) => {
278
- const containerName = asString(container.name) ?? "container";
279
-
280
- return asArray(container.ports)
281
- .map((entry) => asRecord(entry))
282
- .filter((entry): entry is Record<string, unknown> => entry !== undefined)
283
- .map((entry) => {
284
- const port = entry.containerPort ?? "?";
285
- const protocol = asString(entry.protocol) ?? "TCP";
286
- const hostPort = entry.hostPort ? ` host ${entry.hostPort}` : "";
287
- return `${containerName}: ${String(port)}/${protocol}${hostPort}`;
288
- });
289
- });
290
-
291
- const mountLines = containers.flatMap((container) => {
292
- const containerName = asString(container.name) ?? "container";
293
-
294
- return asArray(container.volumeMounts)
295
- .map((entry) => asRecord(entry))
296
- .filter((entry): entry is Record<string, unknown> => entry !== undefined)
297
- .map((entry) => {
298
- const mountPath = asString(entry.mountPath) ?? "?";
299
- const mountName = asString(entry.name) ?? "volume";
300
- return `${containerName}: ${mountPath} <- ${mountName}${asBoolean(entry.readOnly) ? " (ro)" : ""}`;
301
- });
302
- });
303
-
304
- const probeLines = containers.map((container) => {
305
- const containerName = asString(container.name) ?? "container";
306
- return `${containerName}: readiness ${formatProbe(container.readinessProbe)} | liveness ${formatProbe(container.livenessProbe)} | startup ${formatProbe(container.startupProbe)}`;
307
- });
308
-
309
- const conditionLines = asArray(status.conditions)
310
- .map((entry) => asRecord(entry))
311
- .filter((entry): entry is Record<string, unknown> => entry !== undefined)
312
- .map((entry) => {
313
- const type = asString(entry.type) ?? "Condition";
314
- const conditionStatus = asString(entry.status) ?? "Unknown";
315
- const transition = asString(entry.lastTransitionTime) ?? "unknown";
316
- return `${type}: ${conditionStatus} transitioned ${transition}`;
317
- });
318
-
319
- const volumeLines = asArray(spec.volumes)
320
- .map((entry) => asRecord(entry))
321
- .filter((entry): entry is Record<string, unknown> => entry !== undefined)
322
- .map((entry) => `${asString(entry.name) ?? "volume"}: ${formatVolumeSource(entry)}`);
323
-
324
- return [
325
- { title: "Containers", lines: containerLines.length > 0 ? containerLines : ["No containers"] },
326
- {
327
- title: "Environment",
328
- lines: environmentLines.length > 0 ? environmentLines : ["No environment variables or imports"],
329
- },
330
- { title: "Ports", lines: portLines.length > 0 ? portLines : ["No declared ports"] },
331
- { title: "Volume Mounts", lines: mountLines.length > 0 ? mountLines : ["No volume mounts"] },
332
- { title: "Probes", lines: probeLines.length > 0 ? probeLines : ["No probes configured"] },
333
- { title: "Conditions", lines: conditionLines.length > 0 ? conditionLines : ["No conditions reported"] },
334
- { title: "Volumes", lines: volumeLines.length > 0 ? volumeLines : ["No volumes"] },
335
- ];
336
- }
337
-
338
- // ── Event sections ────────────────────────────────────────────────────────────
339
-
340
- interface BuildEventSectionsOptions {
341
- resource: KubernetesResource;
342
- }
343
-
344
- function buildEventSections(options: BuildEventSectionsOptions): ResourceDetailSection[] {
345
- const raw = options.resource as unknown as Record<string, unknown>;
346
- const involvedObject = (raw.involvedObject ?? {}) as Record<string, unknown>;
347
- const source = (raw.source ?? {}) as Record<string, unknown>;
348
-
349
- const lines = [
350
- `type: ${String(raw.type ?? "-")}`,
351
- `reason: ${String(raw.reason ?? "-")}`,
352
- `message: ${String(raw.message ?? "-")}`,
353
- `count: ${String(raw.count ?? "-")}`,
354
- `firstTimestamp: ${String(raw.firstTimestamp ?? "-")}`,
355
- `lastTimestamp: ${String(raw.lastTimestamp ?? "-")}`,
356
- `source: ${String(source.component ?? raw.reportingComponent ?? "-")}`,
357
- `involvedObject: ${String(involvedObject.kind ?? "-")}/${String(involvedObject.name ?? "-")} ns: ${String(involvedObject.namespace ?? "-")}`,
358
- ];
359
-
360
- return [{ title: "Event", lines }];
361
- }
362
-
363
- // ── HPA sections ─────────────────────────────────────────────────────────────
364
-
365
- interface BuildHpaSectionsOptions {
366
- resource: KubernetesResource;
367
- }
368
-
369
- function buildHpaSections(options: BuildHpaSectionsOptions): ResourceDetailSection[] {
370
- const spec = options.resource.spec ?? {};
371
- const status = options.resource.status ?? {};
372
-
373
- const scaleTargetRef = (spec.scaleTargetRef ?? {}) as Record<string, unknown>;
374
- const lines = [
375
- `scaleTargetRef: ${String(scaleTargetRef.kind ?? "-")}/${String(scaleTargetRef.name ?? "-")}`,
376
- `minReplicas: ${String(spec.minReplicas ?? "-")}`,
377
- `maxReplicas: ${String(spec.maxReplicas ?? "-")}`,
378
- `currentReplicas: ${String(status.currentReplicas ?? "-")}`,
379
- `desiredReplicas: ${String(status.desiredReplicas ?? "-")}`,
380
- ];
381
-
382
- const conditions = asArray(status.conditions)
383
- .map((c) => asRecord(c))
384
- .filter((c): c is Record<string, unknown> => c !== undefined)
385
- .map((c) => `${asString(c.type) ?? "Condition"}: ${asString(c.status) ?? "-"} — ${asString(c.message) ?? ""}`);
386
-
387
- const metrics = asArray(status.currentMetrics)
388
- .map((m) => asRecord(m))
389
- .filter((m): m is Record<string, unknown> => m !== undefined)
390
- .map((m) => {
391
- const type = asString(m.type) ?? "metric";
392
- const resource = asRecord(m.resource);
393
- if (resource) {
394
- const current = asRecord(resource.current);
395
- return `${type}/${String(resource.name ?? "-")}: ${String(current?.averageUtilization ?? current?.averageValue ?? "-")}`;
396
- }
397
- return type;
398
- });
399
-
400
- return [
401
- { title: "HPA", lines },
402
- ...(conditions.length > 0 ? [{ title: "Conditions", lines: conditions }] : []),
403
- ...(metrics.length > 0 ? [{ title: "Current Metrics", lines: metrics }] : []),
404
- ];
405
- }
406
-
407
- // ── CronJob sections ──────────────────────────────────────────────────────────
408
-
409
- interface BuildCronJobSectionsOptions {
410
- resource: KubernetesResource;
411
- }
412
-
413
- function buildCronJobSections(options: BuildCronJobSectionsOptions): ResourceDetailSection[] {
414
- const spec = options.resource.spec ?? {};
415
- const status = options.resource.status ?? {};
416
-
417
- const activeJobs = asArray(status.active).map((j) => {
418
- const ref = asRecord(j);
419
- return ref ? `${String(ref.kind ?? "Job")}/${String(ref.name ?? "-")}` : "-";
420
- });
421
-
422
- const lines = [
423
- `schedule: ${asString(spec.schedule) ?? "-"}`,
424
- `suspend: ${String(asBoolean(spec.suspend) ?? false)}`,
425
- `lastScheduleTime: ${asString(status.lastScheduleTime as unknown) ?? "-"}`,
426
- `concurrencyPolicy: ${asString(spec.concurrencyPolicy) ?? "-"}`,
427
- `successfulJobsHistoryLimit: ${String(asNumber(spec.successfulJobsHistoryLimit) ?? "-")}`,
428
- `failedJobsHistoryLimit: ${String(asNumber(spec.failedJobsHistoryLimit) ?? "-")}`,
429
- ...(activeJobs.length > 0 ? [`active: ${activeJobs.join(", ")}`] : []),
430
- ];
431
-
432
- return [{ title: "CronJob", lines }];
433
- }
434
-
435
- // ── Node sections ─────────────────────────────────────────────────────────────
436
-
437
- interface BuildNodeSectionsOptions {
438
- resource: KubernetesResource;
439
- }
440
-
441
- function buildNodeSections(options: BuildNodeSectionsOptions): ResourceDetailSection[] {
442
- const status = options.resource.status ?? {};
443
- const raw = options.resource as unknown as Record<string, unknown>;
444
-
445
- const nodeInfo = (status.nodeInfo ?? {}) as Record<string, unknown>;
446
- const capacity = (status.capacity ?? {}) as Record<string, unknown>;
447
- const allocatable = (status.allocatable ?? {}) as Record<string, unknown>;
448
-
449
- const infoLines = [
450
- `kubeletVersion: ${String(nodeInfo.kubeletVersion ?? "-")}`,
451
- `containerRuntime: ${String(nodeInfo.containerRuntimeVersion ?? "-")}`,
452
- `osImage: ${String(nodeInfo.osImage ?? "-")}`,
453
- `architecture: ${String(nodeInfo.architecture ?? "-")}`,
454
- ];
455
-
456
- const capacityLines = Object.entries(capacity).map(([k, v]) => {
457
- const alloc = allocatable[k];
458
- return alloc !== undefined ? `${k}: ${String(v)} (allocatable: ${String(alloc)})` : `${k}: ${String(v)}`;
459
- });
460
-
461
- const conditions = asArray(status.conditions)
462
- .map((c) => asRecord(c))
463
- .filter((c): c is Record<string, unknown> => c !== undefined)
464
- .map((c) => `${asString(c.type) ?? "Condition"}: ${asString(c.status) ?? "-"} ${asString(c.reason) ?? ""}`);
465
-
466
- const taints = asArray((raw.spec as Record<string, unknown> | undefined)?.taints)
467
- .map((t) => asRecord(t))
468
- .filter((t): t is Record<string, unknown> => t !== undefined)
469
- .map((t) => `${asString(t.key) ?? "key"}:${asString(t.effect) ?? "NoSchedule"}${t.value ? `=${String(t.value)}` : ""}`);
470
-
471
- return [
472
- { title: "Node Info", lines: infoLines },
473
- ...(capacityLines.length > 0 ? [{ title: "Capacity / Allocatable", lines: capacityLines }] : []),
474
- ...(conditions.length > 0 ? [{ title: "Conditions", lines: conditions }] : []),
475
- ...(taints.length > 0 ? [{ title: "Taints", lines: taints }] : []),
476
- ];
477
- }
478
-
479
- // ── RBAC sections ─────────────────────────────────────────────────────────────
480
-
481
- interface BuildRoleSectionsOptions {
482
- resource: KubernetesResource;
483
- }
484
-
485
- function buildRoleSections(options: BuildRoleSectionsOptions): ResourceDetailSection[] {
486
- const raw = options.resource as unknown as Record<string, unknown>;
487
- const rules = asArray(raw.rules)
488
- .map((r) => asRecord(r))
489
- .filter((r): r is Record<string, unknown> => r !== undefined)
490
- .map((r) => {
491
- const verbs = asArray(r.verbs).map((v) => String(v)).join(",");
492
- const resources = asArray(r.resources).map((res) => String(res)).join(",");
493
- const apiGroups = asArray(r.apiGroups).map((g) => String(g) || '""').join(",");
494
- return `[${apiGroups}] ${resources}: ${verbs}`;
495
- });
496
-
497
- return [{ title: "Rules", lines: rules.length > 0 ? rules : ["No rules"] }];
498
- }
499
-
500
- interface BuildRoleBindingSectionsOptions {
501
- resource: KubernetesResource;
502
- }
503
-
504
- function buildRoleBindingSections(options: BuildRoleBindingSectionsOptions): ResourceDetailSection[] {
505
- const raw = options.resource as unknown as Record<string, unknown>;
506
- const roleRef = asRecord(raw.roleRef);
507
- const refLines = roleRef
508
- ? [`${asString(roleRef.kind) ?? "-"} / ${asString(roleRef.name) ?? "-"}`]
509
- : ["No roleRef"];
510
-
511
- const subjects = asArray(raw.subjects)
512
- .map((s) => asRecord(s))
513
- .filter((s): s is Record<string, unknown> => s !== undefined)
514
- .map((s) => {
515
- const ns = asString(s.namespace);
516
- return `${asString(s.kind) ?? "-"} ${ns ? `${ns}/` : ""}${asString(s.name) ?? "-"}`;
517
- });
518
-
519
- return [
520
- { title: "RoleRef", lines: refLines },
521
- { title: "Subjects", lines: subjects.length > 0 ? subjects : ["No subjects"] },
522
- ];
523
- }
524
-
525
- interface BuildServiceAccountSectionsOptions {
526
- resource: KubernetesResource;
527
- }
528
-
529
- function buildServiceAccountSections(options: BuildServiceAccountSectionsOptions): ResourceDetailSection[] {
530
- const raw = options.resource as unknown as Record<string, unknown>;
531
- const secrets = asArray(raw.secrets)
532
- .map((s) => asRecord(s))
533
- .filter((s): s is Record<string, unknown> => s !== undefined)
534
- .map((s) => asString(s.name) ?? "-");
535
-
536
- const imagePullSecrets = asArray(raw.imagePullSecrets)
537
- .map((s) => asRecord(s))
538
- .filter((s): s is Record<string, unknown> => s !== undefined)
539
- .map((s) => asString(s.name) ?? "-");
540
-
541
- return [
542
- { title: "Secrets", lines: secrets.length > 0 ? secrets : ["None"] },
543
- ...(imagePullSecrets.length > 0 ? [{ title: "Image Pull Secrets", lines: imagePullSecrets }] : []),
544
- ];
545
- }