openk8s 1.0.1 → 1.0.3

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 (48) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -2
  3. package/src/app/__tests__/app-state.test.ts +376 -0
  4. package/src/app/__tests__/components/inspector-tokens.test.ts +101 -0
  5. package/src/app/__tests__/utils.test.ts +358 -0
  6. package/src/app/app-actions.ts +262 -0
  7. package/src/app/app-state.ts +16 -263
  8. package/src/app/app.tsx +22 -170
  9. package/src/app/components/detail-sections.tsx +131 -0
  10. package/src/app/components/footer.tsx +52 -0
  11. package/src/app/components/header.tsx +37 -0
  12. package/src/app/components/inspector-tokens.ts +93 -0
  13. package/src/app/components/inspector.tsx +3 -239
  14. package/src/app/components/resource-rows.tsx +5 -1
  15. package/src/app/hooks/keyboard/filter-handlers.ts +40 -0
  16. package/src/app/hooks/keyboard/global-handlers.ts +134 -0
  17. package/src/app/hooks/keyboard/helm-handlers.ts +104 -0
  18. package/src/app/hooks/keyboard/logs-handlers.ts +80 -0
  19. package/src/app/hooks/keyboard/navigation-handlers.ts +103 -0
  20. package/src/app/hooks/keyboard/overlay-handlers.ts +138 -0
  21. package/src/app/hooks/keyboard/port-forward-handlers.ts +71 -0
  22. package/src/app/hooks/keyboard/shell-edit-handlers.ts +253 -0
  23. package/src/app/hooks/use-app-keyboard.ts +56 -621
  24. package/src/app/hooks/use-app-side-effects.ts +1 -1
  25. package/src/app/hooks/use-clipboard.ts +1 -1
  26. package/src/app/hooks/use-data-fetching.ts +2 -11
  27. package/src/app/hooks/use-log-stream.ts +1 -1
  28. package/src/app/hooks/use-port-forward.ts +1 -1
  29. package/src/app/use-footer-hints.ts +107 -0
  30. package/src/index.tsx +4 -0
  31. package/src/lib/k8s/__tests__/k8s-format.test.ts +42 -0
  32. package/src/lib/k8s/__tests__/resource-detail-builder.test.ts +215 -0
  33. package/src/lib/k8s/__tests__/resource-parser.test.ts +455 -0
  34. package/src/lib/k8s/detail-builders/event-builder.ts +21 -0
  35. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +63 -0
  36. package/src/lib/k8s/detail-builders/node-builder.ts +41 -0
  37. package/src/lib/k8s/detail-builders/overview-builder.ts +103 -0
  38. package/src/lib/k8s/detail-builders/pod-builder.ts +140 -0
  39. package/src/lib/k8s/detail-builders/rbac-builder.ts +57 -0
  40. package/src/lib/k8s/resource-detail-builder.ts +22 -502
  41. package/src/lib/kubectl/__tests__/kubectl-helpers.test.ts +343 -0
  42. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +84 -0
  43. package/src/lib/kubectl/__tests__/spawn-utils.test.ts +56 -0
  44. package/src/lib/kubectl/kubectl-helpers.ts +246 -0
  45. package/src/lib/kubectl/kubectl-service.ts +77 -565
  46. package/src/lib/kubectl/kubectl-types.ts +248 -0
  47. package/src/lib/kubectl/metrics-utils.ts +33 -0
  48. package/src/lib/kubectl/spawn-utils.ts +27 -0
@@ -0,0 +1,455 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ asRecord,
4
+ asString,
5
+ asNumber,
6
+ asBoolean,
7
+ asArray,
8
+ asPort,
9
+ suggestedLocalPort,
10
+ summarizeLabels,
11
+ summarizeIngressHosts,
12
+ isPodKind,
13
+ normalizeKind,
14
+ supportsDirectPortForward,
15
+ flattenSections,
16
+ extractContainerPorts,
17
+ extractContainerNames,
18
+ formatContainerState,
19
+ formatValueFrom,
20
+ buildEnvLine,
21
+ formatEnvFromSource,
22
+ formatProbe,
23
+ formatVolumeSource,
24
+ } from "../resource-parser";
25
+
26
+ // ── Type guards ──────────────────────────────────────────────────────────────
27
+
28
+ describe("asRecord", () => {
29
+ test("returns record for plain object", () => {
30
+ expect(asRecord({ a: 1 })).toEqual({ a: 1 });
31
+ });
32
+
33
+ test("returns undefined for null", () => {
34
+ expect(asRecord(null)).toBeUndefined();
35
+ });
36
+
37
+ test("returns undefined for array", () => {
38
+ expect(asRecord([1, 2])).toBeUndefined();
39
+ });
40
+
41
+ test("returns undefined for string", () => {
42
+ expect(asRecord("hello")).toBeUndefined();
43
+ });
44
+
45
+ test("returns undefined for number", () => {
46
+ expect(asRecord(42)).toBeUndefined();
47
+ });
48
+
49
+ test("returns undefined for undefined", () => {
50
+ expect(asRecord(undefined)).toBeUndefined();
51
+ });
52
+ });
53
+
54
+ describe("asString", () => {
55
+ test("returns string for non-empty string", () => {
56
+ expect(asString("hello")).toBe("hello");
57
+ });
58
+
59
+ test("returns undefined for empty string", () => {
60
+ expect(asString("")).toBeUndefined();
61
+ });
62
+
63
+ test("returns undefined for non-string", () => {
64
+ expect(asString(42)).toBeUndefined();
65
+ });
66
+ });
67
+
68
+ describe("asNumber", () => {
69
+ test("returns number for number", () => {
70
+ expect(asNumber(42)).toBe(42);
71
+ });
72
+
73
+ test("returns undefined for non-number", () => {
74
+ expect(asNumber("42")).toBeUndefined();
75
+ });
76
+
77
+ test("returns zero for zero", () => {
78
+ expect(asNumber(0)).toBe(0);
79
+ });
80
+ });
81
+
82
+ describe("asBoolean", () => {
83
+ test("returns true", () => {
84
+ expect(asBoolean(true)).toBe(true);
85
+ });
86
+
87
+ test("returns false", () => {
88
+ expect(asBoolean(false)).toBe(false);
89
+ });
90
+
91
+ test("returns undefined for non-boolean", () => {
92
+ expect(asBoolean("true")).toBeUndefined();
93
+ });
94
+ });
95
+
96
+ describe("asArray", () => {
97
+ test("returns array for array", () => {
98
+ expect(asArray([1, 2, 3])).toEqual([1, 2, 3]);
99
+ });
100
+
101
+ test("returns empty array for non-array", () => {
102
+ expect(asArray(null)).toEqual([]);
103
+ expect(asArray(undefined)).toEqual([]);
104
+ expect(asArray("hello")).toEqual([]);
105
+ });
106
+ });
107
+
108
+ // ── Port utilities ───────────────────────────────────────────────────────────
109
+
110
+ describe("asPort", () => {
111
+ test("valid number port", () => {
112
+ expect(asPort(8080)).toBe(8080);
113
+ });
114
+
115
+ test("valid string port", () => {
116
+ expect(asPort("8080")).toBe(8080);
117
+ });
118
+
119
+ test("invalid port 0", () => {
120
+ expect(asPort(0)).toBeUndefined();
121
+ });
122
+
123
+ test("out of range port", () => {
124
+ expect(asPort(70000)).toBeUndefined();
125
+ });
126
+
127
+ test("non-numeric string", () => {
128
+ expect(asPort("abc")).toBeUndefined();
129
+ });
130
+ });
131
+
132
+ describe("suggestedLocalPort", () => {
133
+ test("maps 80 to 8080", () => {
134
+ expect(suggestedLocalPort(80)).toBe(8080);
135
+ });
136
+
137
+ test("maps 443 to 8443", () => {
138
+ expect(suggestedLocalPort(443)).toBe(8443);
139
+ });
140
+
141
+ test("keeps other ports unchanged", () => {
142
+ expect(suggestedLocalPort(3000)).toBe(3000);
143
+ expect(suggestedLocalPort(9090)).toBe(9090);
144
+ expect(suggestedLocalPort(22)).toBe(22);
145
+ });
146
+ });
147
+
148
+ // ── Label utilities ──────────────────────────────────────────────────────────
149
+
150
+ describe("summarizeLabels", () => {
151
+ test("returns 'none' for empty labels", () => {
152
+ expect(summarizeLabels({})).toBe("none");
153
+ });
154
+
155
+ test("returns 'none' for undefined", () => {
156
+ expect(summarizeLabels(undefined)).toBe("none");
157
+ });
158
+
159
+ test("formats single label", () => {
160
+ expect(summarizeLabels({ app: "nginx" })).toBe("app=nginx");
161
+ });
162
+
163
+ test("formats multiple labels", () => {
164
+ expect(summarizeLabels({ app: "nginx", env: "prod", version: "1.0" })).toBe("app=nginx, env=prod, version=1.0");
165
+ });
166
+
167
+ test("limits to 6 labels", () => {
168
+ const labels = { a: "1", b: "2", c: "3", d: "4", e: "5", f: "6", g: "7" };
169
+ const result = summarizeLabels(labels);
170
+ expect(result).toBe("a=1, b=2, c=3, d=4, e=5, f=6");
171
+ });
172
+ });
173
+
174
+ describe("summarizeIngressHosts", () => {
175
+ test("returns undefined when no rules", () => {
176
+ expect(summarizeIngressHosts({})).toBeUndefined();
177
+ });
178
+
179
+ test("returns undefined when rules have no host", () => {
180
+ expect(summarizeIngressHosts({ rules: [{ http: { paths: [] } }] })).toBeUndefined();
181
+ });
182
+
183
+ test("formats single host", () => {
184
+ expect(summarizeIngressHosts({ rules: [{ host: "example.com" }] })).toBe("example.com");
185
+ });
186
+
187
+ test("formats multiple hosts", () => {
188
+ expect(summarizeIngressHosts({ rules: [{ host: "a.com" }, { host: "b.com" }] })).toBe("a.com, b.com");
189
+ });
190
+ });
191
+
192
+ // ── Kind helpers ─────────────────────────────────────────────────────────────
193
+
194
+ describe("isPodKind", () => {
195
+ test("pod matches", () => expect(isPodKind("pod")).toBe(true));
196
+ test("pods matches", () => expect(isPodKind("pods")).toBe(true));
197
+ test("Pod matches", () => expect(isPodKind("Pod")).toBe(true));
198
+ test("POD matches", () => expect(isPodKind("POD")).toBe(true));
199
+ test("deployment does not match", () => expect(isPodKind("deployment")).toBe(false));
200
+ test("service does not match", () => expect(isPodKind("service")).toBe(false));
201
+ });
202
+
203
+ describe("normalizeKind", () => {
204
+ test("lowercases", () => expect(normalizeKind("Deployment")).toBe("deployment"));
205
+ test("trims whitespace", () => expect(normalizeKind(" Pod ")).toBe("pod"));
206
+ });
207
+
208
+ describe("supportsDirectPortForward", () => {
209
+ const supported = ["pod", "pods", "service", "services", "deployment", "deployments",
210
+ "replicaset", "replicasets", "statefulset", "statefulsets"];
211
+
212
+ for (const kind of supported) {
213
+ test(`${kind} is supported`, () => expect(supportsDirectPortForward(kind)).toBe(true));
214
+ }
215
+
216
+ test("ingress is not supported", () => expect(supportsDirectPortForward("ingress")).toBe(false));
217
+ test("configmap is not supported", () => expect(supportsDirectPortForward("configmap")).toBe(false));
218
+ });
219
+
220
+ // ── Flatten sections ─────────────────────────────────────────────────────────
221
+
222
+ describe("flattenSections", () => {
223
+ test("flattens sections with string lines", () => {
224
+ const sections = [
225
+ { title: "Overview", lines: ["kind: Pod", "name: test"] },
226
+ { title: "Containers", lines: ["nginx image nginx"] },
227
+ ];
228
+ const result = flattenSections(sections);
229
+ expect(result).toEqual([
230
+ "Overview",
231
+ " kind: Pod",
232
+ " name: test",
233
+ "Containers",
234
+ " nginx image nginx",
235
+ ]);
236
+ });
237
+
238
+ test("handles structured lines", () => {
239
+ const sections = [
240
+ {
241
+ title: "Environment",
242
+ lines: [{ id: "e1", text: "KEY=value" }],
243
+ },
244
+ ];
245
+ const result = flattenSections(sections);
246
+ expect(result).toEqual(["Environment", " KEY=value"]);
247
+ });
248
+ });
249
+
250
+ // ── Container parsing ────────────────────────────────────────────────────────
251
+
252
+ describe("extractContainerPorts", () => {
253
+ test("extracts ports from containers", () => {
254
+ const spec = {
255
+ containers: [
256
+ { ports: [{ containerPort: 80 }, { containerPort: 443 }] },
257
+ { ports: [{ containerPort: 8080 }] },
258
+ ],
259
+ };
260
+ const ports = extractContainerPorts(spec);
261
+ expect(ports).toEqual(expect.arrayContaining([80, 443, 8080]));
262
+ expect(ports).toHaveLength(3);
263
+ });
264
+
265
+ test("deduplicates ports", () => {
266
+ const spec = {
267
+ containers: [
268
+ { ports: [{ containerPort: 80 }] },
269
+ { ports: [{ containerPort: 80 }] },
270
+ ],
271
+ };
272
+ const ports = extractContainerPorts(spec);
273
+ expect(ports).toEqual([80]);
274
+ });
275
+
276
+ test("returns empty for no containers", () => {
277
+ expect(extractContainerPorts({})).toEqual([]);
278
+ expect(extractContainerPorts(null)).toEqual([]);
279
+ });
280
+ });
281
+
282
+ describe("extractContainerNames", () => {
283
+ test("extracts container names", () => {
284
+ const spec = {
285
+ containers: [{ name: "nginx" }, { name: "sidecar" }],
286
+ };
287
+ expect(extractContainerNames(spec)).toEqual(["nginx", "sidecar"]);
288
+ });
289
+
290
+ test("returns empty for no containers", () => {
291
+ expect(extractContainerNames({})).toEqual([]);
292
+ expect(extractContainerNames(null)).toEqual([]);
293
+ });
294
+ });
295
+
296
+ // ── Formatting functions ─────────────────────────────────────────────────────
297
+
298
+ describe("formatContainerState", () => {
299
+ test("running state", () => {
300
+ expect(formatContainerState({ running: {} })).toBe("running");
301
+ });
302
+
303
+ test("waiting state with reason", () => {
304
+ expect(formatContainerState({ waiting: { reason: "CrashLoopBackOff" } })).toBe("waiting (CrashLoopBackOff)");
305
+ });
306
+
307
+ test("waiting state without reason", () => {
308
+ expect(formatContainerState({ waiting: {} })).toBe("waiting (unknown)");
309
+ });
310
+
311
+ test("terminated state with reason", () => {
312
+ expect(formatContainerState({ terminated: { reason: "Completed" } })).toBe("terminated (Completed)");
313
+ });
314
+
315
+ test("undefined state", () => {
316
+ expect(formatContainerState(undefined)).toBe("waiting");
317
+ });
318
+
319
+ test("null state", () => {
320
+ expect(formatContainerState(null)).toBe("waiting");
321
+ });
322
+ });
323
+
324
+ describe("formatValueFrom", () => {
325
+ test("fieldRef", () => {
326
+ expect(formatValueFrom({ fieldRef: { fieldPath: "metadata.name" } })).toBe("field:metadata.name");
327
+ });
328
+
329
+ test("resourceFieldRef", () => {
330
+ expect(formatValueFrom({ resourceFieldRef: { resource: "limits.cpu" } })).toBe("resource:limits.cpu");
331
+ });
332
+
333
+ test("configMapKeyRef", () => {
334
+ expect(formatValueFrom({ configMapKeyRef: { name: "myconfig", key: "mykey" } })).toBe("configmap:myconfig/mykey");
335
+ });
336
+
337
+ test("secretKeyRef", () => {
338
+ expect(formatValueFrom({ secretKeyRef: { name: "mysecret", key: "password" } })).toBe("secret:mysecret/password");
339
+ });
340
+
341
+ test("undefined valueFrom", () => {
342
+ expect(formatValueFrom(undefined)).toBe("-");
343
+ });
344
+ });
345
+
346
+ describe("buildEnvLine", () => {
347
+ test("builds non-revealable line", () => {
348
+ const line = buildEnvLine("env:1", "KEY=value");
349
+ expect(line).toEqual({ id: "env:1", text: "KEY=value" });
350
+ });
351
+
352
+ test("builds revealable line with secret ref", () => {
353
+ const line = buildEnvLine("env:2", "KEY=*****", "secret:mysecret/password", { name: "mysecret", key: "password" });
354
+ expect(line).toEqual({
355
+ id: "env:2",
356
+ text: "KEY=*****",
357
+ revealedText: "secret:mysecret/password",
358
+ revealable: true,
359
+ secretRef: { name: "mysecret", key: "password" },
360
+ });
361
+ });
362
+
363
+ test("builds revealable line without key in secret ref", () => {
364
+ const line = buildEnvLine("env:3", "envFrom: secret/s", "envFrom: secret/s (all keys imported)", { name: "s" });
365
+ expect(line).toEqual({
366
+ id: "env:3",
367
+ text: "envFrom: secret/s",
368
+ revealedText: "envFrom: secret/s (all keys imported)",
369
+ revealable: true,
370
+ secretRef: { name: "s" },
371
+ });
372
+ });
373
+ });
374
+
375
+ describe("formatEnvFromSource", () => {
376
+ test("configMapRef", () => {
377
+ expect(formatEnvFromSource({ configMapRef: { name: "myconfig" } })).toBe("configmap:myconfig");
378
+ });
379
+
380
+ test("configMapRef with prefix", () => {
381
+ expect(formatEnvFromSource({ prefix: "PREFIX_", configMapRef: { name: "myconfig" } })).toBe("PREFIX_ <= configmap:myconfig");
382
+ });
383
+
384
+ test("secretRef", () => {
385
+ expect(formatEnvFromSource({ secretRef: { name: "mysecret" } })).toBe("secret:mysecret");
386
+ });
387
+
388
+ test("unknown source", () => {
389
+ expect(formatEnvFromSource({})).toBe("-");
390
+ });
391
+
392
+ test("undefined", () => {
393
+ expect(formatEnvFromSource(undefined)).toBe("-");
394
+ });
395
+ });
396
+
397
+ describe("formatProbe", () => {
398
+ test("disabled when no probe", () => {
399
+ expect(formatProbe(undefined)).toBe("disabled");
400
+ });
401
+
402
+ test("httpGet probe", () => {
403
+ expect(formatProbe({ httpGet: { path: "/healthz", port: 8080 } })).toBe("http /healthz port 8080");
404
+ });
405
+
406
+ test("tcp socket probe", () => {
407
+ expect(formatProbe({ tcpSocket: { port: 3306 } })).toBe("tcp port 3306");
408
+ });
409
+
410
+ test("exec probe", () => {
411
+ expect(formatProbe({ exec: { command: ["cat", "/tmp/healthy"] } })).toBe("exec cat /tmp/healthy");
412
+ });
413
+
414
+ test("grpc probe", () => {
415
+ expect(formatProbe({ grpc: { port: 50051 } })).toBe("grpc port 50051");
416
+ });
417
+
418
+ test("configured fallback", () => {
419
+ expect(formatProbe({})).toBe("configured");
420
+ });
421
+ });
422
+
423
+ describe("formatVolumeSource", () => {
424
+ test("configMap", () => {
425
+ expect(formatVolumeSource({ configMap: { name: "myconfig" } })).toBe("configmap:myconfig");
426
+ });
427
+
428
+ test("secret", () => {
429
+ expect(formatVolumeSource({ secret: { secretName: "mysecret" } })).toBe("secret:mysecret");
430
+ });
431
+
432
+ test("persistentVolumeClaim", () => {
433
+ expect(formatVolumeSource({ persistentVolumeClaim: { claimName: "mypvc" } })).toBe("pvc:mypvc");
434
+ });
435
+
436
+ test("projected", () => {
437
+ expect(formatVolumeSource({ projected: {} })).toBe("projected");
438
+ });
439
+
440
+ test("emptyDir", () => {
441
+ expect(formatVolumeSource({ emptyDir: {} })).toBe("emptyDir");
442
+ });
443
+
444
+ test("downwardAPI", () => {
445
+ expect(formatVolumeSource({ downwardAPI: {} })).toBe("downwardAPI");
446
+ });
447
+
448
+ test("unknown", () => {
449
+ expect(formatVolumeSource({})).toBe("other");
450
+ });
451
+
452
+ test("undefined", () => {
453
+ expect(formatVolumeSource(undefined)).toBe("unknown");
454
+ });
455
+ });
@@ -0,0 +1,21 @@
1
+ import type { ResourceDetailSection } from "../types";
2
+ import type { KubernetesResource } from "../resource-parser";
3
+
4
+ export function buildEventSections(resource: KubernetesResource): ResourceDetailSection[] {
5
+ const raw = resource as unknown as Record<string, unknown>;
6
+ const involvedObject = (raw.involvedObject ?? {}) as Record<string, unknown>;
7
+ const source = (raw.source ?? {}) as Record<string, unknown>;
8
+
9
+ const lines = [
10
+ `type: ${String(raw.type ?? "-")}`,
11
+ `reason: ${String(raw.reason ?? "-")}`,
12
+ `message: ${String(raw.message ?? "-")}`,
13
+ `count: ${String(raw.count ?? "-")}`,
14
+ `firstTimestamp: ${String(raw.firstTimestamp ?? "-")}`,
15
+ `lastTimestamp: ${String(raw.lastTimestamp ?? "-")}`,
16
+ `source: ${String(source.component ?? raw.reportingComponent ?? "-")}`,
17
+ `involvedObject: ${String(involvedObject.kind ?? "-")}/${String(involvedObject.name ?? "-")} ns: ${String(involvedObject.namespace ?? "-")}`,
18
+ ];
19
+
20
+ return [{ title: "Event", lines }];
21
+ }
@@ -0,0 +1,63 @@
1
+ import { asArray, asBoolean, asNumber, asRecord, asString } from "../resource-parser";
2
+ import type { ResourceDetailSection } from "../types";
3
+ import type { KubernetesResource } from "../resource-parser";
4
+
5
+ export function buildHpaSections(resource: KubernetesResource): ResourceDetailSection[] {
6
+ const spec = resource.spec ?? {};
7
+ const status = resource.status ?? {};
8
+
9
+ const scaleTargetRef = (spec.scaleTargetRef ?? {}) as Record<string, unknown>;
10
+ const lines = [
11
+ `scaleTargetRef: ${String(scaleTargetRef.kind ?? "-")}/${String(scaleTargetRef.name ?? "-")}`,
12
+ `minReplicas: ${String(spec.minReplicas ?? "-")}`,
13
+ `maxReplicas: ${String(spec.maxReplicas ?? "-")}`,
14
+ `currentReplicas: ${String(status.currentReplicas ?? "-")}`,
15
+ `desiredReplicas: ${String(status.desiredReplicas ?? "-")}`,
16
+ ];
17
+
18
+ const conditions = asArray(status.conditions)
19
+ .map((c) => asRecord(c))
20
+ .filter((c): c is Record<string, unknown> => c !== undefined)
21
+ .map((c) => `${asString(c.type) ?? "Condition"}: ${asString(c.status) ?? "-"} — ${asString(c.message) ?? ""}`);
22
+
23
+ const metrics = asArray(status.currentMetrics)
24
+ .map((m) => asRecord(m))
25
+ .filter((m): m is Record<string, unknown> => m !== undefined)
26
+ .map((m) => {
27
+ const type = asString(m.type) ?? "metric";
28
+ const resource = asRecord(m.resource);
29
+ if (resource) {
30
+ const current = asRecord(resource.current);
31
+ return `${type}/${String(resource.name ?? "-")}: ${String(current?.averageUtilization ?? current?.averageValue ?? "-")}`;
32
+ }
33
+ return type;
34
+ });
35
+
36
+ return [
37
+ { title: "HPA", lines },
38
+ ...(conditions.length > 0 ? [{ title: "Conditions", lines: conditions }] : []),
39
+ ...(metrics.length > 0 ? [{ title: "Current Metrics", lines: metrics }] : []),
40
+ ];
41
+ }
42
+
43
+ export function buildCronJobSections(resource: KubernetesResource): ResourceDetailSection[] {
44
+ const spec = resource.spec ?? {};
45
+ const status = resource.status ?? {};
46
+
47
+ const activeJobs = asArray(status.active).map((j) => {
48
+ const ref = asRecord(j);
49
+ return ref ? `${String(ref.kind ?? "Job")}/${String(ref.name ?? "-")}` : "-";
50
+ });
51
+
52
+ const lines = [
53
+ `schedule: ${asString(spec.schedule) ?? "-"}`,
54
+ `suspend: ${String(asBoolean(spec.suspend) ?? false)}`,
55
+ `lastScheduleTime: ${asString(status.lastScheduleTime as unknown) ?? "-"}`,
56
+ `concurrencyPolicy: ${asString(spec.concurrencyPolicy) ?? "-"}`,
57
+ `successfulJobsHistoryLimit: ${String(asNumber(spec.successfulJobsHistoryLimit) ?? "-")}`,
58
+ `failedJobsHistoryLimit: ${String(asNumber(spec.failedJobsHistoryLimit) ?? "-")}`,
59
+ ...(activeJobs.length > 0 ? [`active: ${activeJobs.join(", ")}`] : []),
60
+ ];
61
+
62
+ return [{ title: "CronJob", lines }];
63
+ }
@@ -0,0 +1,41 @@
1
+ import { asArray, asRecord, asString } from "../resource-parser";
2
+ import type { ResourceDetailSection } from "../types";
3
+ import type { KubernetesResource } from "../resource-parser";
4
+
5
+ export function buildNodeSections(resource: KubernetesResource): ResourceDetailSection[] {
6
+ const status = resource.status ?? {};
7
+ const raw = resource as unknown as Record<string, unknown>;
8
+
9
+ const nodeInfo = (status.nodeInfo ?? {}) as Record<string, unknown>;
10
+ const capacity = (status.capacity ?? {}) as Record<string, unknown>;
11
+ const allocatable = (status.allocatable ?? {}) as Record<string, unknown>;
12
+
13
+ const infoLines = [
14
+ `kubeletVersion: ${String(nodeInfo.kubeletVersion ?? "-")}`,
15
+ `containerRuntime: ${String(nodeInfo.containerRuntimeVersion ?? "-")}`,
16
+ `osImage: ${String(nodeInfo.osImage ?? "-")}`,
17
+ `architecture: ${String(nodeInfo.architecture ?? "-")}`,
18
+ ];
19
+
20
+ const capacityLines = Object.entries(capacity).map(([k, v]) => {
21
+ const alloc = allocatable[k];
22
+ return alloc !== undefined ? `${k}: ${String(v)} (allocatable: ${String(alloc)})` : `${k}: ${String(v)}`;
23
+ });
24
+
25
+ const conditions = asArray(status.conditions)
26
+ .map((c) => asRecord(c))
27
+ .filter((c): c is Record<string, unknown> => c !== undefined)
28
+ .map((c) => `${asString(c.type) ?? "Condition"}: ${asString(c.status) ?? "-"} ${asString(c.reason) ?? ""}`);
29
+
30
+ const taints = asArray((raw.spec as Record<string, unknown> | undefined)?.taints)
31
+ .map((t) => asRecord(t))
32
+ .filter((t): t is Record<string, unknown> => t !== undefined)
33
+ .map((t) => `${asString(t.key) ?? "key"}:${asString(t.effect) ?? "NoSchedule"}${t.value ? `=${String(t.value)}` : ""}`);
34
+
35
+ return [
36
+ { title: "Node Info", lines: infoLines },
37
+ ...(capacityLines.length > 0 ? [{ title: "Capacity / Allocatable", lines: capacityLines }] : []),
38
+ ...(conditions.length > 0 ? [{ title: "Conditions", lines: conditions }] : []),
39
+ ...(taints.length > 0 ? [{ title: "Taints", lines: taints }] : []),
40
+ ];
41
+ }
@@ -0,0 +1,103 @@
1
+ import {
2
+ asArray, asNumber, asPort, asRecord, asString,
3
+ extractContainerNames, isPodKind, normalizeKind,
4
+ suggestedLocalPort, summarizeIngressHosts, summarizeLabels,
5
+ } from "../resource-parser";
6
+ import type { ResourceDetailSection, ResourceKind, ResourcePortForward } from "../types";
7
+ import type { KubernetesResource } from "../resource-parser";
8
+
9
+ export function buildPortForward(
10
+ resourceKind: ResourceKind | undefined,
11
+ resource: KubernetesResource,
12
+ ): ResourcePortForward[] {
13
+ const kindName = resource.kind ?? resourceKind?.name ?? "unknown";
14
+
15
+ if (!supportsDirectPortForward(kindName)) {
16
+ return [];
17
+ }
18
+
19
+ const spec = resource.spec ?? {};
20
+ let remotePorts: number[];
21
+
22
+ if (["service", "services"].includes(normalizeKind(kindName))) {
23
+ remotePorts = asArray(spec.ports)
24
+ .map((port) => asPort(asRecord(port)?.port))
25
+ .filter((port): port is number => port !== undefined);
26
+ } else if (isPodKind(kindName)) {
27
+ remotePorts = extractContainerPortsFromSpec(spec);
28
+ } else {
29
+ remotePorts = extractContainerPortsFromSpec(asRecord(spec.template)?.spec);
30
+ }
31
+
32
+ return remotePorts.map((remotePort) => ({
33
+ localPort: suggestedLocalPort(remotePort),
34
+ remotePort,
35
+ }));
36
+ }
37
+
38
+ function extractContainerPortsFromSpec(specValue: unknown): number[] {
39
+ const spec = asRecord(specValue);
40
+ if (!spec) return [];
41
+ return asArray(spec.containers)
42
+ .map((container) => asRecord(container))
43
+ .filter((c): c is Record<string, unknown> => c !== undefined)
44
+ .flatMap((c) => asArray(c.ports).map((p) => asPort(asRecord(p)?.containerPort)).filter((p): p is number => p !== undefined));
45
+ }
46
+
47
+ function supportsDirectPortForward(kind: string): boolean {
48
+ const normalized = normalizeKind(kind);
49
+ return ["pod", "pods", "service", "services", "deployment", "deployments", "replicaset", "replicasets", "statefulset", "statefulsets"].includes(normalized);
50
+ }
51
+
52
+ export function buildOverviewSection(
53
+ resourceKind: ResourceKind | undefined,
54
+ resource: KubernetesResource,
55
+ ): { section: ResourceDetailSection; replicas?: number | undefined } {
56
+ const metadata = resource.metadata ?? {};
57
+ const status = resource.status ?? {};
58
+ const spec = resource.spec ?? {};
59
+
60
+ const lines = [
61
+ `kind: ${resource.kind ?? resourceKind?.name ?? "unknown"}`,
62
+ `apiVersion: ${resource.apiVersion ?? "unknown"}`,
63
+ `name: ${metadata.name ?? "unknown"}`,
64
+ `namespace: ${metadata.namespace ?? (resourceKind?.namespaced ? "default" : "cluster")}`,
65
+ `created: ${metadata.creationTimestamp ?? "unknown"}`,
66
+ `labels: ${summarizeLabels(metadata.labels)}`,
67
+ ];
68
+
69
+ const replicas = asNumber(spec.replicas);
70
+ if (replicas !== undefined) lines.push(`replicas: ${replicas}`);
71
+
72
+ const readyReplicas = asNumber(status.readyReplicas);
73
+ if (readyReplicas !== undefined) lines.push(`readyReplicas: ${readyReplicas}`);
74
+
75
+ const phase = asString(status.phase);
76
+ if (phase) lines.push(`phase: ${phase}`);
77
+
78
+ const qosClass = asString(status.qosClass);
79
+ if (qosClass) lines.push(`qos: ${qosClass}`);
80
+
81
+ const clusterIP = asString(spec.clusterIP);
82
+ if (clusterIP) lines.push(`clusterIP: ${clusterIP}`);
83
+
84
+ const nodeName = asString(spec.nodeName);
85
+ if (nodeName) lines.push(`node: ${nodeName}`);
86
+
87
+ const podIP = asString(status.podIP);
88
+ if (podIP) lines.push(`podIP: ${podIP}`);
89
+
90
+ const hostIP = asString(status.hostIP);
91
+ if (hostIP) lines.push(`hostIP: ${hostIP}`);
92
+
93
+ const serviceAccount = asString(spec.serviceAccountName);
94
+ if (serviceAccount) lines.push(`serviceAccount: ${serviceAccount}`);
95
+
96
+ const completions = asNumber(status.succeeded);
97
+ if (completions !== undefined) lines.push(`succeeded: ${completions}`);
98
+
99
+ const ingressHosts = summarizeIngressHosts(spec);
100
+ if (ingressHosts) lines.push(`hosts: ${ingressHosts}`);
101
+
102
+ return { section: { title: "Overview", lines }, replicas };
103
+ }