openk8s 1.0.4 → 1.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 (71) hide show
  1. package/README.md +1 -1
  2. package/package.json +11 -10
  3. package/src/app/__tests__/app-state.test.ts +47 -5
  4. package/src/app/__tests__/utils.test.ts +5 -14
  5. package/src/app/app-actions.ts +5 -0
  6. package/src/app/app-state.ts +11 -2
  7. package/src/app/app.tsx +57 -46
  8. package/src/app/components/detail-sections.tsx +2 -2
  9. package/src/app/components/footer.tsx +23 -30
  10. package/src/app/components/inspector.tsx +28 -43
  11. package/src/app/components/kind-rows.tsx +1 -1
  12. package/src/app/components/notification-card.tsx +145 -0
  13. package/src/app/components/notification-tray.tsx +19 -38
  14. package/src/app/components/overlays/delete-confirm-overlay.tsx +15 -17
  15. package/src/app/components/overlays/helm-rollback-overlay.tsx +12 -66
  16. package/src/app/components/overlays/help-overlay.tsx +173 -0
  17. package/src/app/components/overlays/index.ts +5 -2
  18. package/src/app/components/overlays/logs-dialog.tsx +50 -80
  19. package/src/app/components/overlays/notification-history-overlay.tsx +79 -0
  20. package/src/app/components/overlays/port-forward-list-overlay.tsx +85 -0
  21. package/src/app/components/overlays/port-forward-overlay.tsx +16 -56
  22. package/src/app/components/overlays/scale-dialog.tsx +12 -67
  23. package/src/app/components/overlays/select-overlay.tsx +5 -14
  24. package/src/app/components/overlays/shared.tsx +85 -6
  25. package/src/app/components/resource-rows.tsx +1 -1
  26. package/src/app/constants.ts +24 -0
  27. package/src/app/hooks/keyboard/filter-handlers.ts +2 -1
  28. package/src/app/hooks/keyboard/global-handlers.ts +32 -11
  29. package/src/app/hooks/keyboard/helm-handlers.ts +14 -9
  30. package/src/app/hooks/keyboard/keys.ts +18 -0
  31. package/src/app/hooks/keyboard/logs-handlers.ts +3 -1
  32. package/src/app/hooks/keyboard/navigation-handlers.ts +5 -4
  33. package/src/app/hooks/keyboard/overlay-handlers.ts +11 -7
  34. package/src/app/hooks/keyboard/port-forward-handlers.ts +19 -14
  35. package/src/app/hooks/keyboard/shell-edit-handlers.ts +45 -17
  36. package/src/app/hooks/use-app-keyboard.ts +22 -2
  37. package/src/app/hooks/use-app-side-effects.ts +10 -7
  38. package/src/app/hooks/use-clipboard.ts +8 -10
  39. package/src/app/hooks/use-data-fetching.ts +11 -5
  40. package/src/app/hooks/use-log-stream.ts +8 -4
  41. package/src/app/hooks/use-notifications.ts +92 -0
  42. package/src/app/hooks/use-port-forward.ts +19 -4
  43. package/src/app/persistence.ts +7 -3
  44. package/src/app/syntax-theme.ts +31 -0
  45. package/src/app/theme.ts +2 -3
  46. package/src/app/use-footer-hints.ts +21 -16
  47. package/src/app/utils.ts +1 -9
  48. package/src/assets/tree-sitter/yaml/highlights.scm +79 -0
  49. package/src/assets/tree-sitter/yaml/tree-sitter-yaml.wasm +0 -0
  50. package/src/index.tsx +22 -2
  51. package/src/lib/k8s/__tests__/k8s-format.test.ts +17 -0
  52. package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
  53. package/src/lib/k8s/detail-builders/event-builder.ts +17 -11
  54. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +34 -31
  55. package/src/lib/k8s/detail-builders/node-builder.ts +22 -11
  56. package/src/lib/k8s/detail-builders/overview-builder.ts +4 -17
  57. package/src/lib/k8s/detail-builders/pod-builder.ts +52 -24
  58. package/src/lib/k8s/detail-builders/rbac-builder.ts +20 -29
  59. package/src/lib/k8s/k8s-format.ts +14 -9
  60. package/src/lib/k8s/resource-detail-builder.ts +1 -1
  61. package/src/lib/k8s/resource-parser.ts +17 -2
  62. package/src/lib/k8s/types.ts +10 -1
  63. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +9 -0
  64. package/src/lib/kubectl/kubectl-helpers.ts +16 -11
  65. package/src/lib/kubectl/kubectl-service.ts +39 -39
  66. package/src/lib/kubectl/kubectl-types.ts +10 -1
  67. package/src/lib/kubectl/metrics-utils.ts +21 -7
  68. package/src/lib/kubectl/spawn-utils.ts +50 -11
  69. package/src/app/__tests__/components/inspector-tokens.test.ts +0 -101
  70. package/src/app/components/inspector-tokens.ts +0 -93
  71. package/src/app/components/port-forwards-tray.tsx +0 -57
@@ -1,16 +1,19 @@
1
1
  import { useMemo } from "react";
2
2
 
3
- import { GLYPHS, KEY_HINT, TEXT_MUTED, TEXT_SUBTLE } from "./theme";
4
- import type { AppState, ActivePortForward, ResourceDetail, ResourceRef } from "../lib/k8s/types";
3
+ import { GLYPHS } from "./theme";
4
+ import { FOOTER_PADDING, HINT_CELL_OVERHEAD } from "./constants";
5
+ import type { ActivePortForward, PaneId, ResourceDetail, ResourceListItem, ResourceRef } from "../lib/k8s/types";
6
+ import { isHelmRelease } from "../lib/kubectl/kubectl-helpers";
5
7
  import { KubectlService } from "../lib/kubectl/kubectl-service";
6
8
 
7
9
  export function useFooterHints(
8
10
  filterMode: boolean,
9
- state: AppState,
11
+ activePane: PaneId,
10
12
  activeResourceRef: ResourceRef | undefined,
11
13
  activeDetail: ResourceDetail | undefined,
12
14
  selectedResourcePortForwards: ActivePortForward[],
13
- deleteTargets: { length: number },
15
+ activePortForwardsCount: number,
16
+ deleteTargets: ReadonlyArray<ResourceListItem>,
14
17
  kubectl: KubectlService,
15
18
  ): [string, string][] {
16
19
  return useMemo((): [string, string][] => {
@@ -22,12 +25,12 @@ export function useFooterHints(
22
25
  ];
23
26
  }
24
27
 
25
- const isHelm = activeResourceRef?.kind.toLowerCase() === "helmreleases";
28
+ const isHelm = isHelmRelease(activeResourceRef);
26
29
  const hasResource = Boolean(activeResourceRef);
27
30
  const hasPortForwards =
28
31
  (activeDetail?.portForwards?.length ?? 0) > 0 || selectedResourcePortForwards.length > 0;
29
32
 
30
- if (state.activePane === "clusters") {
33
+ if (activePane === "clusters") {
31
34
  return [
32
35
  [GLYPHS.tab, "panes"],
33
36
  ["↑↓", "kinds"],
@@ -35,7 +38,7 @@ export function useFooterHints(
35
38
  ];
36
39
  }
37
40
 
38
- if (state.activePane === "inspector") {
41
+ if (activePane === "inspector") {
39
42
  return [
40
43
  [GLYPHS.tab, "panes"],
41
44
  ["←", "back"],
@@ -48,17 +51,17 @@ export function useFooterHints(
48
51
  ["↑↓", "navigate"],
49
52
  ["/", "filter"],
50
53
  ["R", "refresh"],
54
+ ["⇧N", "history"],
55
+ ...(activePortForwardsCount > 0 ? [["⇧F", "forwards"] as [string, string]] : []),
56
+ ["?", "help"],
51
57
  ];
52
58
 
53
59
  if (!hasResource) return hints;
54
60
 
55
61
  if (isHelm) {
56
- hints.push(
57
- ["E", "edit values"],
58
- ...(deleteTargets.length > 0 ? [["D", "delete"] as [string, string]] : []),
59
- ["B", "rollback"],
60
- ["U", "upgrade"],
61
- );
62
+ hints.push(["E", "edit values"]);
63
+ if (deleteTargets.length > 0) hints.push(["D", "delete"]);
64
+ hints.push(["B", "rollback"], ["U", "upgrade"]);
62
65
  } else {
63
66
  hints.push(["L", "logs"], ["E", "edit"], ["X", "shell"]);
64
67
  if (hasPortForwards) hints.push(["F", "forward"]);
@@ -70,11 +73,13 @@ export function useFooterHints(
70
73
  return hints;
71
74
  }, [
72
75
  filterMode,
73
- state.activePane,
76
+ activePane,
74
77
  activeResourceRef,
75
78
  activeDetail,
76
79
  selectedResourcePortForwards,
80
+ activePortForwardsCount,
77
81
  deleteTargets,
82
+ kubectl,
78
83
  ]);
79
84
  }
80
85
 
@@ -83,14 +88,14 @@ export function useFooterHintRows(
83
88
  terminalWidth: number,
84
89
  ): { footerHintsRow1: [string, string][]; footerHintsRow2: [string, string][] } {
85
90
  return useMemo(() => {
86
- const innerWidth = terminalWidth - 4;
91
+ const innerWidth = terminalWidth - FOOTER_PADDING;
87
92
  const row1: [string, string][] = [];
88
93
  const row2: [string, string][] = [];
89
94
  let used1 = 0;
90
95
  let used2 = 0;
91
96
 
92
97
  for (const [key, label] of footerHints) {
93
- const w = key.length + label.length + 3;
98
+ const w = key.length + label.length + HINT_CELL_OVERHEAD;
94
99
  const gap1 = row1.length === 0 ? 0 : 2;
95
100
  const gap2 = row2.length === 0 ? 0 : 2;
96
101
  if (used1 + gap1 + w <= innerWidth) {
package/src/app/utils.ts CHANGED
@@ -52,11 +52,6 @@ export interface ResourcePreviewOptions {
52
52
  resource?: ResourceListItem | undefined;
53
53
  }
54
54
 
55
- export interface CurrentKindLabelOptions {
56
- resourceKinds: ResourceKind[];
57
- selectedKind: string;
58
- }
59
-
60
55
  export interface PortForwardIdOptions {
61
56
  context: string;
62
57
  namespace: string;
@@ -185,10 +180,6 @@ export function kindDescription(kind: ResourceKind): string {
185
180
  return `${scope}${shortNames}`;
186
181
  }
187
182
 
188
- export function currentKindLabel(options: CurrentKindLabelOptions): string {
189
- return options.resourceKinds.find((kind) => kind.name === options.selectedKind)?.name ?? options.selectedKind;
190
- }
191
-
192
183
  export function resourcePreview(options: ResourcePreviewOptions): string[] {
193
184
  if (!options.resource) {
194
185
  return ["Select a resource to inspect it."];
@@ -263,6 +254,7 @@ export function selectedRef(options: SelectedRefOptions): ResourceRef | undefine
263
254
  const EKS_ARN_RE = /^arn:aws:eks:[^:]+:\d{12}:cluster\/(.+)$/;
264
255
 
265
256
  export function truncate(s: string, maxLen: number): string {
257
+ if (maxLen <= 0) return s.length > 0 ? "\u2026" : s;
266
258
  if (s.length <= maxLen) return s;
267
259
  return s.slice(0, maxLen - 1) + "\u2026";
268
260
  }
@@ -0,0 +1,79 @@
1
+ (boolean_scalar) @boolean
2
+
3
+ (null_scalar) @constant.builtin
4
+
5
+ [
6
+ (double_quote_scalar)
7
+ (single_quote_scalar)
8
+ (block_scalar)
9
+ (string_scalar)
10
+ ] @string
11
+
12
+ [
13
+ (integer_scalar)
14
+ (float_scalar)
15
+ ] @number
16
+
17
+ (comment) @comment
18
+
19
+ [
20
+ (anchor_name)
21
+ (alias_name)
22
+ ] @label
23
+
24
+ (tag) @type
25
+
26
+ [
27
+ (yaml_directive)
28
+ (tag_directive)
29
+ (reserved_directive)
30
+ ] @attribute
31
+
32
+ (block_mapping_pair
33
+ key: (flow_node
34
+ [
35
+ (double_quote_scalar)
36
+ (single_quote_scalar)
37
+ ] @property))
38
+
39
+ (block_mapping_pair
40
+ key: (flow_node
41
+ (plain_scalar
42
+ (string_scalar) @property)))
43
+
44
+ (flow_mapping
45
+ (_
46
+ key: (flow_node
47
+ [
48
+ (double_quote_scalar)
49
+ (single_quote_scalar)
50
+ ] @property)))
51
+
52
+ (flow_mapping
53
+ (_
54
+ key: (flow_node
55
+ (plain_scalar
56
+ (string_scalar) @property))))
57
+
58
+ [
59
+ ","
60
+ "-"
61
+ ":"
62
+ ">"
63
+ "?"
64
+ "|"
65
+ ] @punctuation.delimiter
66
+
67
+ [
68
+ "["
69
+ "]"
70
+ "{"
71
+ "}"
72
+ ] @punctuation.bracket
73
+
74
+ [
75
+ "*"
76
+ "&"
77
+ "---"
78
+ "..."
79
+ ] @punctuation.special
package/src/index.tsx CHANGED
@@ -1,11 +1,31 @@
1
- import { createCliRenderer } from "@opentui/core";
1
+ import { join } from "node:path";
2
+ import { createCliRenderer, addDefaultParsers } from "@opentui/core";
2
3
  import { createRoot } from "@opentui/react";
3
- import { setAbortController } from "./lib/kubectl/spawn-utils";
4
+ import { setAbortController, abortRunningProcesses } from "./lib/kubectl/spawn-utils";
4
5
  import { App } from "./app/app";
5
6
 
7
+ const yamlAssetsDir = join(import.meta.dirname, "assets", "tree-sitter", "yaml");
8
+ addDefaultParsers([
9
+ {
10
+ filetype: "yaml",
11
+ aliases: ["yml"],
12
+ queries: {
13
+ highlights: [join(yamlAssetsDir, "highlights.scm")],
14
+ },
15
+ wasm: join(yamlAssetsDir, "tree-sitter-yaml.wasm"),
16
+ },
17
+ ]);
18
+
6
19
  const abortController = new AbortController();
7
20
  setAbortController(abortController);
8
21
 
22
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
23
+ process.on(signal, () => {
24
+ abortRunningProcesses();
25
+ process.exit(0);
26
+ });
27
+ }
28
+
9
29
  const renderer = await createCliRenderer({
10
30
  useMouse: true,
11
31
  });
@@ -39,4 +39,21 @@ describe("formatAge", () => {
39
39
  test("returns '-' for invalid date", () => {
40
40
  expect(formatAge("not-a-date")).toBe("-");
41
41
  });
42
+
43
+ test("parses helm-style timestamp with offset as UTC", () => {
44
+ const result = formatAge("2024-01-15 10:30:00.000000000 +0000 UTC");
45
+ expect(result).not.toBe("-");
46
+ });
47
+
48
+ test("parses helm-style timestamp without offset as UTC (not local)", () => {
49
+ // Regression: helm values without +0000 used to be parsed in local time.
50
+ // Using a far-past date so the result is well-formed regardless of timezone.
51
+ const result = formatAge("2000-01-01 00:00:00.000 UTC");
52
+ expect(result).toMatch(/^\d+d$/);
53
+ });
54
+
55
+ test("does not double-append Z to ISO strings", () => {
56
+ const result = formatAge("2024-01-15T10:30:00Z");
57
+ expect(result).not.toBe("-");
58
+ });
42
59
  });
@@ -21,6 +21,7 @@ import {
21
21
  formatEnvFromSource,
22
22
  formatProbe,
23
23
  formatVolumeSource,
24
+ recordsOf,
24
25
  } from "../resource-parser";
25
26
 
26
27
  // ── Type guards ──────────────────────────────────────────────────────────────
@@ -77,6 +78,18 @@ describe("asNumber", () => {
77
78
  test("returns zero for zero", () => {
78
79
  expect(asNumber(0)).toBe(0);
79
80
  });
81
+
82
+ test("returns undefined for NaN", () => {
83
+ expect(asNumber(NaN)).toBeUndefined();
84
+ });
85
+
86
+ test("returns undefined for Infinity", () => {
87
+ expect(asNumber(Infinity)).toBeUndefined();
88
+ });
89
+
90
+ test("does not let NaN leak through nullish-coalesce fallbacks", () => {
91
+ expect(asNumber(NaN) ?? 0).toBe(0);
92
+ });
80
93
  });
81
94
 
82
95
  describe("asBoolean", () => {
@@ -277,6 +290,31 @@ describe("extractContainerPorts", () => {
277
290
  expect(extractContainerPorts({})).toEqual([]);
278
291
  expect(extractContainerPorts(null)).toEqual([]);
279
292
  });
293
+
294
+ test("extracts ports from pod-template spec (used by overview-builder)", () => {
295
+ const template = {
296
+ spec: {
297
+ containers: [
298
+ { ports: [{ containerPort: 8080 }] },
299
+ { ports: [{ containerPort: 8080 }] },
300
+ ],
301
+ },
302
+ };
303
+ const ports = extractContainerPorts((template as Record<string, unknown>).spec);
304
+ expect(ports).toEqual([8080]);
305
+ });
306
+ });
307
+
308
+ describe("recordsOf", () => {
309
+ test("returns records from an array", () => {
310
+ expect(recordsOf([{ a: 1 }, "x", { b: 2 }])).toEqual([{ a: 1 }, { b: 2 }]);
311
+ });
312
+
313
+ test("returns empty for non-array", () => {
314
+ expect(recordsOf(undefined)).toEqual([]);
315
+ expect(recordsOf(null)).toEqual([]);
316
+ expect(recordsOf({})).toEqual([]);
317
+ });
280
318
  });
281
319
 
282
320
  describe("extractContainerNames", () => {
@@ -313,11 +351,11 @@ describe("formatContainerState", () => {
313
351
  });
314
352
 
315
353
  test("undefined state", () => {
316
- expect(formatContainerState(undefined)).toBe("waiting");
354
+ expect(formatContainerState(undefined)).toBe("unknown");
317
355
  });
318
356
 
319
357
  test("null state", () => {
320
- expect(formatContainerState(null)).toBe("waiting");
358
+ expect(formatContainerState(null)).toBe("unknown");
321
359
  });
322
360
  });
323
361
 
@@ -1,20 +1,26 @@
1
+ import { asRecord, asString } from "../resource-parser";
2
+ import { formatAge } from "../k8s-format";
1
3
  import type { ResourceDetailSection } from "../types";
2
4
  import type { KubernetesResource } from "../resource-parser";
3
5
 
6
+ function asStringOrDash(value: unknown): string {
7
+ return asString(value) ?? "-";
8
+ }
9
+
4
10
  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>;
11
+ const raw = asRecord(resource) ?? {};
12
+ const involvedObject = asRecord(raw.involvedObject) ?? {};
13
+ const source = asRecord(raw.source) ?? {};
8
14
 
9
15
  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 ?? "-")}`,
16
+ `type: ${asStringOrDash(raw.type)}`,
17
+ `reason: ${asStringOrDash(raw.reason)}`,
18
+ `message: ${asStringOrDash(raw.message)}`,
19
+ `count: ${asStringOrDash(raw.count)}`,
20
+ `firstTimestamp: ${formatAge(asString(raw.firstTimestamp))}`,
21
+ `lastTimestamp: ${formatAge(asString(raw.lastTimestamp))}`,
22
+ `source: ${asStringOrDash(source.component ?? raw.reportingComponent)}`,
23
+ `involvedObject: ${asStringOrDash(involvedObject.kind)}/${asStringOrDash(involvedObject.name)} ns: ${asStringOrDash(involvedObject.namespace)}`,
18
24
  ];
19
25
 
20
26
  return [{ title: "Event", lines }];
@@ -1,37 +1,40 @@
1
- import { asArray, asBoolean, asNumber, asRecord, asString } from "../resource-parser";
1
+ import { asArray, asBoolean, asNumber, asRecord, asString, recordsOf } from "../resource-parser";
2
2
  import type { ResourceDetailSection } from "../types";
3
3
  import type { KubernetesResource } from "../resource-parser";
4
4
 
5
+ function asStringOrDash(value: unknown): string {
6
+ return asString(value) ?? "-";
7
+ }
8
+
5
9
  export function buildHpaSections(resource: KubernetesResource): ResourceDetailSection[] {
6
10
  const spec = resource.spec ?? {};
7
11
  const status = resource.status ?? {};
8
12
 
9
- const scaleTargetRef = (spec.scaleTargetRef ?? {}) as Record<string, unknown>;
13
+ const scaleTargetRef = asRecord(spec.scaleTargetRef) ?? {};
10
14
  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 ?? "-")}`,
15
+ `scaleTargetRef: ${asStringOrDash(scaleTargetRef.kind)}/${asStringOrDash(scaleTargetRef.name)}`,
16
+ `minReplicas: ${asNumber(spec.minReplicas) ?? "-"}`,
17
+ `maxReplicas: ${asNumber(spec.maxReplicas) ?? "-"}`,
18
+ `currentReplicas: ${asNumber(status.currentReplicas) ?? "-"}`,
19
+ `desiredReplicas: ${asNumber(status.desiredReplicas) ?? "-"}`,
16
20
  ];
17
21
 
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
- });
22
+ const conditions = recordsOf(status.conditions).map((c) => {
23
+ const message = asString(c.message);
24
+ return `${asStringOrDash(c.type)}: ${asStringOrDash(c.status)}${message ? ` ${message}` : ""}`;
25
+ });
26
+
27
+ const metrics = recordsOf(status.currentMetrics).map((m) => {
28
+ const type = asString(m.type) ?? "metric";
29
+ const resource = asRecord(m.resource);
30
+ if (resource) {
31
+ const current = asRecord(resource.current);
32
+ const value = current?.averageUtilization ?? current?.averageValue;
33
+ const label = current?.averageUtilization !== undefined ? `${value}%` : asStringOrDash(value);
34
+ return `${type}/${asStringOrDash(resource.name)}: ${label}`;
35
+ }
36
+ return type;
37
+ });
35
38
 
36
39
  return [
37
40
  { title: "HPA", lines },
@@ -46,16 +49,16 @@ export function buildCronJobSections(resource: KubernetesResource): ResourceDeta
46
49
 
47
50
  const activeJobs = asArray(status.active).map((j) => {
48
51
  const ref = asRecord(j);
49
- return ref ? `${String(ref.kind ?? "Job")}/${String(ref.name ?? "-")}` : "-";
52
+ return ref ? `${asStringOrDash(ref.kind) ?? "Job"}/${asStringOrDash(ref.name)}` : "-";
50
53
  });
51
54
 
52
55
  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) ?? "-")}`,
56
+ `schedule: ${asStringOrDash(spec.schedule)}`,
57
+ `suspend: ${asBoolean(spec.suspend) ?? false}`,
58
+ `lastScheduleTime: ${asStringOrDash(status.lastScheduleTime)}`,
59
+ `concurrencyPolicy: ${asStringOrDash(spec.concurrencyPolicy) ?? "Allow"}`,
60
+ `successfulJobsHistoryLimit: ${asNumber(spec.successfulJobsHistoryLimit) ?? "-"}`,
61
+ `failedJobsHistoryLimit: ${asNumber(spec.failedJobsHistoryLimit) ?? "-"}`,
59
62
  ...(activeJobs.length > 0 ? [`active: ${activeJobs.join(", ")}`] : []),
60
63
  ];
61
64
 
@@ -4,17 +4,17 @@ import type { KubernetesResource } from "../resource-parser";
4
4
 
5
5
  export function buildNodeSections(resource: KubernetesResource): ResourceDetailSection[] {
6
6
  const status = resource.status ?? {};
7
- const raw = resource as unknown as Record<string, unknown>;
7
+ const spec = asRecord(resource.spec) ?? {};
8
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>;
9
+ const nodeInfo = asRecord(status.nodeInfo) ?? {};
10
+ const capacity = asRecord(status.capacity) ?? {};
11
+ const allocatable = asRecord(status.allocatable) ?? {};
12
12
 
13
13
  const infoLines = [
14
- `kubeletVersion: ${String(nodeInfo.kubeletVersion ?? "-")}`,
15
- `containerRuntime: ${String(nodeInfo.containerRuntimeVersion ?? "-")}`,
16
- `osImage: ${String(nodeInfo.osImage ?? "-")}`,
17
- `architecture: ${String(nodeInfo.architecture ?? "-")}`,
14
+ `kubeletVersion: ${asString(nodeInfo.kubeletVersion) ?? "-"}`,
15
+ `containerRuntime: ${asString(nodeInfo.containerRuntimeVersion) ?? "-"}`,
16
+ `osImage: ${asString(nodeInfo.osImage) ?? "-"}`,
17
+ `architecture: ${asString(nodeInfo.architecture) ?? "-"}`,
18
18
  ];
19
19
 
20
20
  const capacityLines = Object.entries(capacity).map(([k, v]) => {
@@ -25,12 +25,23 @@ export function buildNodeSections(resource: KubernetesResource): ResourceDetailS
25
25
  const conditions = asArray(status.conditions)
26
26
  .map((c) => asRecord(c))
27
27
  .filter((c): c is Record<string, unknown> => c !== undefined)
28
- .map((c) => `${asString(c.type) ?? "Condition"}: ${asString(c.status) ?? "-"} ${asString(c.reason) ?? ""}`);
28
+ .map((c) => {
29
+ const type = asString(c.type) ?? "Condition";
30
+ const conditionStatus = asString(c.status) ?? "-";
31
+ const reason = asString(c.reason);
32
+ return reason ? `${type}: ${conditionStatus} ${reason}` : `${type}: ${conditionStatus}`;
33
+ });
29
34
 
30
- const taints = asArray((raw.spec as Record<string, unknown> | undefined)?.taints)
35
+ // kubectl convention: key=value:effect (or key:effect when value-less)
36
+ const taints = asArray(spec.taints)
31
37
  .map((t) => asRecord(t))
32
38
  .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)}` : ""}`);
39
+ .map((t) => {
40
+ const key = asString(t.key) ?? "key";
41
+ const effect = asString(t.effect) ?? "NoSchedule";
42
+ const value = asString(t.value);
43
+ return value ? `${key}=${value}:${effect}` : `${key}:${effect}`;
44
+ });
34
45
 
35
46
  return [
36
47
  { title: "Node Info", lines: infoLines },
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  asArray, asNumber, asPort, asRecord, asString,
3
- extractContainerNames, isPodKind, normalizeKind,
3
+ extractContainerNames, extractContainerPorts, isPodKind, normalizeKind,
4
4
  suggestedLocalPort, summarizeIngressHosts, summarizeLabels,
5
+ supportsDirectPortForward,
5
6
  } from "../resource-parser";
6
7
  import type { ResourceDetailSection, ResourceKind, ResourcePortForward } from "../types";
7
8
  import type { KubernetesResource } from "../resource-parser";
@@ -24,9 +25,9 @@ export function buildPortForward(
24
25
  .map((port) => asPort(asRecord(port)?.port))
25
26
  .filter((port): port is number => port !== undefined);
26
27
  } else if (isPodKind(kindName)) {
27
- remotePorts = extractContainerPortsFromSpec(spec);
28
+ remotePorts = extractContainerPorts(spec);
28
29
  } else {
29
- remotePorts = extractContainerPortsFromSpec(asRecord(spec.template)?.spec);
30
+ remotePorts = extractContainerPorts(asRecord(spec.template)?.spec);
30
31
  }
31
32
 
32
33
  return remotePorts.map((remotePort) => ({
@@ -35,20 +36,6 @@ export function buildPortForward(
35
36
  }));
36
37
  }
37
38
 
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
39
  export function buildOverviewSection(
53
40
  resourceKind: ResourceKind | undefined,
54
41
  resource: KubernetesResource,