openk8s 1.0.5 → 1.2.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 (72) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -10
  3. package/src/app/__tests__/app-state.test.ts +48 -6
  4. package/src/app/__tests__/utils.test.ts +6 -15
  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 +40 -83
  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 +171 -0
  17. package/src/app/components/overlays/index.ts +4 -1
  18. package/src/app/components/overlays/logs-dialog.tsx +47 -67
  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 +30 -21
  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 +32 -16
  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 +10 -6
  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-detail-builder.test.ts +4 -6
  53. package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
  54. package/src/lib/k8s/detail-builders/event-builder.ts +18 -12
  55. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +34 -31
  56. package/src/lib/k8s/detail-builders/node-builder.ts +22 -11
  57. package/src/lib/k8s/detail-builders/overview-builder.ts +4 -17
  58. package/src/lib/k8s/detail-builders/pod-builder.ts +52 -24
  59. package/src/lib/k8s/detail-builders/rbac-builder.ts +20 -29
  60. package/src/lib/k8s/k8s-format.ts +14 -9
  61. package/src/lib/k8s/resource-detail-builder.ts +4 -7
  62. package/src/lib/k8s/resource-parser.ts +17 -2
  63. package/src/lib/k8s/types.ts +12 -4
  64. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +9 -0
  65. package/src/lib/kubectl/kubectl-helpers.ts +16 -11
  66. package/src/lib/kubectl/kubectl-service.ts +38 -54
  67. package/src/lib/kubectl/kubectl-types.ts +10 -1
  68. package/src/lib/kubectl/metrics-utils.ts +21 -7
  69. package/src/lib/kubectl/spawn-utils.ts +50 -11
  70. package/src/app/__tests__/components/inspector-tokens.test.ts +0 -101
  71. package/src/app/components/inspector-tokens.ts +0 -93
  72. package/src/app/components/port-forwards-tray.tsx +0 -57
@@ -3,35 +3,60 @@ import {
3
3
  buildEnvLine, formatContainerState, formatEnvFromSource,
4
4
  formatProbe, formatValueFrom, formatVolumeSource,
5
5
  } from "../resource-parser";
6
+ import { formatAge } from "../k8s-format";
6
7
  import type { ResourceDetailSection } from "../types";
7
8
  import type { KubernetesResource } from "../resource-parser";
8
9
 
10
+ interface ContainerInfo {
11
+ record: Record<string, unknown>;
12
+ isInit: boolean;
13
+ statusEntry: Record<string, unknown> | undefined;
14
+ }
15
+
16
+ function collectContainers(spec: Record<string, unknown>, status: Record<string, unknown>): ContainerInfo[] {
17
+ const statusMap = new Map<string, Record<string, unknown>>();
18
+ for (const key of ["containerStatuses", "initContainerStatuses"] as const) {
19
+ for (const entry of asArray(status[key]).map(asRecord)) {
20
+ if (entry && asString(entry.name)) {
21
+ statusMap.set(asString(entry.name) ?? "", entry);
22
+ }
23
+ }
24
+ }
25
+
26
+ const collect = (field: string, isInit: boolean): ContainerInfo[] =>
27
+ asArray(spec[field])
28
+ .map(asRecord)
29
+ .filter((entry): entry is Record<string, unknown> => entry !== undefined)
30
+ .map((record) => {
31
+ const name = asString(record.name) ?? "";
32
+ return { record, isInit, statusEntry: statusMap.get(name) };
33
+ });
34
+
35
+ return [...collect("initContainers", true), ...collect("containers", false)];
36
+ }
37
+
38
+ function containerLabel(info: ContainerInfo): string {
39
+ const name = asString(info.record.name) ?? "container";
40
+ return info.isInit ? `${name} (init)` : name;
41
+ }
42
+
9
43
  export function buildPodSections(resource: KubernetesResource): ResourceDetailSection[] {
10
44
  const spec = resource.spec ?? {};
11
45
  const status = resource.status ?? {};
46
+ const containers = collectContainers(spec, status);
12
47
 
13
- const containerStatuses = new Map(
14
- asArray(status.containerStatuses)
15
- .map((entry) => asRecord(entry))
16
- .filter((entry): entry is Record<string, unknown> => Boolean(entry && asString(entry.name)))
17
- .map((entry) => [asString(entry.name) ?? "", entry]),
18
- );
19
-
20
- const containers = asArray(spec.containers)
21
- .map((entry) => asRecord(entry))
22
- .filter((entry): entry is Record<string, unknown> => entry !== undefined);
23
-
24
- const containerLines = containers.map((container) => {
25
- const name = asString(container.name) ?? "container";
26
- const statusEntry = containerStatuses.get(name);
48
+ const containerLines = containers.map((info) => {
49
+ const name = containerLabel(info);
50
+ const statusEntry = info.statusEntry;
27
51
  const ready = asBoolean(statusEntry?.ready);
28
52
  const restartCount = asNumber(statusEntry?.restartCount);
29
53
 
30
- return `${name} image ${asString(container.image) ?? "-"} ready ${ready === undefined ? "-" : ready ? "yes" : "no"} restarts ${restartCount ?? 0} state ${formatContainerState(statusEntry?.state)}`;
54
+ return `${name} image ${asString(info.record.image) ?? "-"} ready ${ready === undefined ? "-" : ready ? "yes" : "no"} restarts ${restartCount ?? 0} state ${formatContainerState(statusEntry?.state)}`;
31
55
  });
32
56
 
33
- const environmentLines = containers.flatMap((container) => {
34
- const containerName = asString(container.name) ?? "container";
57
+ const environmentLines = containers.flatMap((info) => {
58
+ const containerName = containerLabel(info);
59
+ const container = info.record;
35
60
  const envLines = asArray(container.env)
36
61
  .map((entry) => asRecord(entry))
37
62
  .filter((entry): entry is Record<string, unknown> => entry !== undefined)
@@ -78,8 +103,9 @@ export function buildPodSections(resource: KubernetesResource): ResourceDetailSe
78
103
  return [...envLines, ...envFromLines];
79
104
  });
80
105
 
81
- const portLines = containers.flatMap((container) => {
82
- const containerName = asString(container.name) ?? "container";
106
+ const portLines = containers.flatMap((info) => {
107
+ const containerName = containerLabel(info);
108
+ const container = info.record;
83
109
 
84
110
  return asArray(container.ports)
85
111
  .map((entry) => asRecord(entry))
@@ -92,8 +118,9 @@ export function buildPodSections(resource: KubernetesResource): ResourceDetailSe
92
118
  });
93
119
  });
94
120
 
95
- const mountLines = containers.flatMap((container) => {
96
- const containerName = asString(container.name) ?? "container";
121
+ const mountLines = containers.flatMap((info) => {
122
+ const containerName = containerLabel(info);
123
+ const container = info.record;
97
124
 
98
125
  return asArray(container.volumeMounts)
99
126
  .map((entry) => asRecord(entry))
@@ -105,8 +132,9 @@ export function buildPodSections(resource: KubernetesResource): ResourceDetailSe
105
132
  });
106
133
  });
107
134
 
108
- const probeLines = containers.map((container) => {
109
- const containerName = asString(container.name) ?? "container";
135
+ const probeLines = containers.map((info) => {
136
+ const containerName = containerLabel(info);
137
+ const container = info.record;
110
138
  return `${containerName}: readiness ${formatProbe(container.readinessProbe)} | liveness ${formatProbe(container.livenessProbe)} | startup ${formatProbe(container.startupProbe)}`;
111
139
  });
112
140
 
@@ -116,7 +144,7 @@ export function buildPodSections(resource: KubernetesResource): ResourceDetailSe
116
144
  .map((entry) => {
117
145
  const type = asString(entry.type) ?? "Condition";
118
146
  const conditionStatus = asString(entry.status) ?? "Unknown";
119
- const transition = asString(entry.lastTransitionTime) ?? "unknown";
147
+ const transition = formatAge(asString(entry.lastTransitionTime));
120
148
  return `${type}: ${conditionStatus} transitioned ${transition}`;
121
149
  });
122
150
 
@@ -1,36 +1,34 @@
1
- import { asArray, asRecord, asString } from "../resource-parser";
1
+ import { asArray, 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 buildRoleSections(resource: KubernetesResource): ResourceDetailSection[] {
6
- const raw = resource as unknown as Record<string, unknown>;
7
- const rules = asArray(raw.rules)
8
- .map((r) => asRecord(r))
9
- .filter((r): r is Record<string, unknown> => r !== undefined)
10
- .map((r) => {
11
- const verbs = asArray(r.verbs).map((v) => String(v)).join(",");
12
- const resources = asArray(r.resources).map((res) => String(res)).join(",");
13
- const apiGroups = asArray(r.apiGroups).map((g) => String(g) || '""').join(",");
14
- return `[${apiGroups}] ${resources}: ${verbs}`;
15
- });
10
+ const raw = asRecord(resource) ?? {};
11
+ const rules = recordsOf(raw.rules).map((r) => {
12
+ const verbs = asArray(r.verbs).map(asStringOrDash).join(",");
13
+ const resources = asArray(r.resources).map(asStringOrDash).join(",");
14
+ const apiGroups = asArray(r.apiGroups).map((g) => asString(g) ?? '""').join(",");
15
+ return `[${apiGroups}] ${resources}: ${verbs}`;
16
+ });
16
17
 
17
18
  return [{ title: "Rules", lines: rules.length > 0 ? rules : ["No rules"] }];
18
19
  }
19
20
 
20
21
  export function buildRoleBindingSections(resource: KubernetesResource): ResourceDetailSection[] {
21
- const raw = resource as unknown as Record<string, unknown>;
22
+ const raw = asRecord(resource) ?? {};
22
23
  const roleRef = asRecord(raw.roleRef);
23
24
  const refLines = roleRef
24
25
  ? [`${asString(roleRef.kind) ?? "-"} / ${asString(roleRef.name) ?? "-"}`]
25
26
  : ["No roleRef"];
26
27
 
27
- const subjects = asArray(raw.subjects)
28
- .map((s) => asRecord(s))
29
- .filter((s): s is Record<string, unknown> => s !== undefined)
30
- .map((s) => {
31
- const ns = asString(s.namespace);
32
- return `${asString(s.kind) ?? "-"} ${ns ? `${ns}/` : ""}${asString(s.name) ?? "-"}`;
33
- });
28
+ const subjects = recordsOf(raw.subjects).map((s) => {
29
+ const ns = asString(s.namespace);
30
+ return `${asString(s.kind) ?? "-"} ${ns ? `${ns}/` : ""}${asString(s.name) ?? "-"}`;
31
+ });
34
32
 
35
33
  return [
36
34
  { title: "RoleRef", lines: refLines },
@@ -39,16 +37,9 @@ export function buildRoleBindingSections(resource: KubernetesResource): Resource
39
37
  }
40
38
 
41
39
  export function buildServiceAccountSections(resource: KubernetesResource): ResourceDetailSection[] {
42
- const raw = resource as unknown as Record<string, unknown>;
43
- const secrets = asArray(raw.secrets)
44
- .map((s) => asRecord(s))
45
- .filter((s): s is Record<string, unknown> => s !== undefined)
46
- .map((s) => asString(s.name) ?? "-");
47
-
48
- const imagePullSecrets = asArray(raw.imagePullSecrets)
49
- .map((s) => asRecord(s))
50
- .filter((s): s is Record<string, unknown> => s !== undefined)
51
- .map((s) => asString(s.name) ?? "-");
40
+ const raw = asRecord(resource) ?? {};
41
+ const secrets = recordsOf(raw.secrets).map((s) => asString(s.name) ?? "-");
42
+ const imagePullSecrets = recordsOf(raw.imagePullSecrets).map((s) => asString(s.name) ?? "-");
52
43
 
53
44
  return [
54
45
  { title: "Secrets", lines: secrets.length > 0 ? secrets : ["None"] },
@@ -3,17 +3,22 @@ export function formatAge(value?: string | undefined): string {
3
3
  return "-";
4
4
  }
5
5
 
6
- // Normalize helm-style timestamps: "2024-05-12 10:30:00.123456789 +0000 UTC"
7
- // into ISO 8601: "2024-05-12T10:30:00.123+00:00"
8
- // Standard ISO strings (from kubectl) pass through unchanged.
9
- const normalized = value
10
- .replace(/ [A-Z]+$/, "") // strip trailing timezone name: " UTC", " EST"
11
- .replace(/(\.\d{3})\d*/, "$1") // truncate sub-millisecond digits
12
- .replace(" ", "T") // first space → T (date/time separator)
13
- .replace(" ", "") // second space (before offset) → removed
6
+ // Standard ISO strings (from kubectl) pass through unchanged (sub-ms digits are truncated).
7
+ // Helm-style timestamps ("2024-05-12 10:30:00.123456789 +0000 UTC") are normalized to ISO 8601.
8
+ const isHelmFormat = value.includes(" ");
9
+ const strippedTz = value.replace(/ [A-Z]+$/, ""); // strip trailing timezone name: " UTC", " EST"
10
+ const hasNumericOffset = /[+-]\d{4}$/.test(strippedTz);
11
+ const normalized = strippedTz
12
+ .replace(/(\.\d{3})\d*/, "$1") // truncate sub-millisecond digits
13
+ .replace(" ", "T") // first space → T (date/time separator)
14
+ .replace(" ", "") // second space (before offset, if any) → removed
14
15
  .replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); // +0000 → +00:00
15
16
 
16
- const created = new Date(normalized);
17
+ // Helm timestamps are UTC. If a helm-format value lacked a numeric offset,
18
+ // append Z so it parses as UTC rather than the host's local timezone.
19
+ const iso = isHelmFormat && !hasNumericOffset ? `${normalized}Z` : normalized;
20
+
21
+ const created = new Date(iso);
17
22
 
18
23
  if (Number.isNaN(created.valueOf())) {
19
24
  return "-";
@@ -1,6 +1,6 @@
1
1
  import type { ResourceDetail, ResourceKind } from "./types";
2
2
  import { isPodKind } from "./resource-parser";
3
- import type { KubernetesResource, KubernetesMetadata } from "./resource-parser";
3
+ import type { KubernetesResource } from "./resource-parser";
4
4
 
5
5
  export { type KubernetesMetadata, type KubernetesResource } from "./resource-parser";
6
6
 
@@ -8,9 +8,9 @@ import { buildOverviewSection, buildPortForward } from "./detail-builders/overvi
8
8
  import { buildPodSections } from "./detail-builders/pod-builder";
9
9
  import { buildHpaSections, buildCronJobSections } from "./detail-builders/hpa-cronjob-builder";
10
10
  import { buildNodeSections } from "./detail-builders/node-builder";
11
- import { buildEventSections } from "./detail-builders/event-builder";
11
+ import { buildEventObjectSections } from "./detail-builders/event-builder";
12
12
  import { buildRoleSections, buildRoleBindingSections, buildServiceAccountSections } from "./detail-builders/rbac-builder";
13
- import { flattenSections, extractContainerNames } from "./resource-parser";
13
+ import { extractContainerNames } from "./resource-parser";
14
14
 
15
15
  export function buildResourceDetail(options: BuildResourceDetailOptions): ResourceDetail {
16
16
  const kindName = options.resource.kind ?? options.resourceKind?.name ?? "unknown";
@@ -28,7 +28,7 @@ export function buildResourceDetail(options: BuildResourceDetailOptions): Resour
28
28
  } else if (normalizedKind === "node") {
29
29
  additionalSections = buildNodeSections(options.resource);
30
30
  } else if (normalizedKind === "event" || normalizedKind === "events") {
31
- additionalSections = buildEventSections(options.resource);
31
+ additionalSections = buildEventObjectSections(options.resource);
32
32
  } else if (normalizedKind === "role" || normalizedKind === "clusterrole") {
33
33
  additionalSections = buildRoleSections(options.resource);
34
34
  } else if (normalizedKind === "rolebinding" || normalizedKind === "clusterrolebinding") {
@@ -47,10 +47,8 @@ export function buildResourceDetail(options: BuildResourceDetailOptions): Resour
47
47
  name: options.resource.metadata?.name ?? "unknown",
48
48
  namespace: options.resource.metadata?.namespace,
49
49
  },
50
- summaryLines: flattenSections(summarySections),
51
50
  summarySections,
52
51
  yaml: options.yaml,
53
- describe: options.describe,
54
52
  replicas: overview.replicas,
55
53
  portForwards: portForward.length > 0 ? portForward : undefined,
56
54
  containers: containers && containers.length > 0 ? containers : undefined,
@@ -61,5 +59,4 @@ export interface BuildResourceDetailOptions {
61
59
  resourceKind?: ResourceKind | undefined;
62
60
  resource: KubernetesResource;
63
61
  yaml: string;
64
- describe?: string | undefined;
65
62
  }
@@ -19,12 +19,17 @@ export function asRecord(value: unknown): Record<string, unknown> | undefined {
19
19
  return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
20
20
  }
21
21
 
22
+ /**
23
+ * Returns the value if it is a non-empty string, otherwise undefined.
24
+ * Note: an explicitly-empty string is treated as absent — callers that need
25
+ * to preserve empty strings should read the field directly.
26
+ */
22
27
  export function asString(value: unknown): string | undefined {
23
28
  return typeof value === "string" && value.length > 0 ? value : undefined;
24
29
  }
25
30
 
26
31
  export function asNumber(value: unknown): number | undefined {
27
- return typeof value === "number" ? value : undefined;
32
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
28
33
  }
29
34
 
30
35
  export function asBoolean(value: unknown): boolean | undefined {
@@ -35,6 +40,16 @@ export function asArray(value: unknown): unknown[] {
35
40
  return Array.isArray(value) ? value : [];
36
41
  }
37
42
 
43
+ /**
44
+ * Returns the records (objects) within an array, dropping non-object entries.
45
+ * Convenience for the common `asArray(x).map(asRecord).filter(isRecord)` pattern.
46
+ */
47
+ export function recordsOf(value: unknown): Record<string, unknown>[] {
48
+ return asArray(value)
49
+ .map(asRecord)
50
+ .filter((entry): entry is Record<string, unknown> => entry !== undefined);
51
+ }
52
+
38
53
  export function summarizeLabels(labels?: Record<string, string> | undefined): string {
39
54
  const pairs = Object.entries(labels ?? {});
40
55
 
@@ -150,7 +165,7 @@ export function formatContainerState(value: unknown): string {
150
165
  const state = asRecord(value);
151
166
 
152
167
  if (!state) {
153
- return "waiting";
168
+ return "unknown";
154
169
  }
155
170
 
156
171
  if (state.running) {
@@ -1,8 +1,8 @@
1
1
  export type PaneId = "clusters" | "resources" | "inspector";
2
2
 
3
- export type OverlayId = "cluster-switcher" | "namespace-switcher" | "delete-confirm" | "port-forward" | "logs" | "scale" | "container-picker" | "helm-rollback";
3
+ export type OverlayId = "cluster-switcher" | "namespace-switcher" | "delete-confirm" | "port-forward" | "port-forward-list" | "logs" | "scale" | "container-picker" | "helm-rollback" | "notification-history" | "help";
4
4
 
5
- export type InspectorTab = "summary" | "yaml" | "events" | "describe";
5
+ export type InspectorTab = "summary" | "yaml";
6
6
 
7
7
  export type LoadStatus = "idle" | "loading" | "ready" | "error";
8
8
 
@@ -68,14 +68,13 @@ export interface ResourceDetailSection {
68
68
 
69
69
  export interface ResourceDetail {
70
70
  ref: ResourceRef;
71
- summaryLines: string[];
72
71
  summarySections: ResourceDetailSection[];
73
72
  yaml: string;
74
- describe?: string | undefined;
75
73
  replicas?: number | undefined;
76
74
  portForwards?: ResourcePortForward[] | undefined;
77
75
  containers?: string[] | undefined;
78
76
  helmChart?: string | undefined;
77
+ helmValues?: string | undefined;
79
78
  }
80
79
 
81
80
  export interface EventItem {
@@ -105,10 +104,18 @@ export interface AppError {
105
104
 
106
105
  export type NotificationTone = "info" | "success" | "warning" | "danger";
107
106
 
107
+ export interface NotificationAction {
108
+ id: string;
109
+ label: string;
110
+ }
111
+
108
112
  export interface Notification {
109
113
  id: string;
110
114
  tone: NotificationTone;
111
115
  message: string;
116
+ persistent: boolean;
117
+ createdAt: number;
118
+ actions: NotificationAction[];
112
119
  }
113
120
 
114
121
  export interface PodMetricEntry {
@@ -158,6 +165,7 @@ export interface AppState {
158
165
  revealedSecretValues: Record<string, string>;
159
166
  activePortForwards: ActivePortForward[];
160
167
  notifications: Notification[];
168
+ notificationHistory: Notification[];
161
169
  podMetrics: Record<string, { cpu: string; memory: string }>;
162
170
  nodeMetrics: Record<string, { cpu: string; memory: string; cpuPct: number; memPct: number }>;
163
171
  helmRollbackRevision: string;
@@ -35,12 +35,21 @@ describe("parseMemBytes", () => {
35
35
  test("Ki", () => expect(parseMemBytes("512Ki")).toBe(512 * 1024));
36
36
  test("Mi", () => expect(parseMemBytes("256Mi")).toBe(256 * 1024 ** 2));
37
37
  test("Gi", () => expect(parseMemBytes("2Gi")).toBe(2 * 1024 ** 3));
38
+ test("Ti", () => expect(parseMemBytes("1Ti")).toBe(1024 ** 4));
39
+ test("Pi", () => expect(parseMemBytes("1Pi")).toBe(1024 ** 5));
40
+ test("Ei", () => expect(parseMemBytes("1Ei")).toBe(1024 ** 6));
38
41
  test("K (decimal)", () => expect(parseMemBytes("1000K")).toBe(1000 * 1000));
39
42
  test("M (decimal)", () => expect(parseMemBytes("10M")).toBe(10 * 1000 ** 2));
40
43
  test("G (decimal)", () => expect(parseMemBytes("1G")).toBe(1000 ** 3));
44
+ test("T (decimal)", () => expect(parseMemBytes("1T")).toBe(1000 ** 4));
45
+ test("P (decimal)", () => expect(parseMemBytes("1P")).toBe(1000 ** 5));
46
+ test("E (decimal)", () => expect(parseMemBytes("1E")).toBe(1000 ** 6));
41
47
  test("plain bytes", () => expect(parseMemBytes("1024")).toBe(1024));
42
48
  test("empty string", () => expect(parseMemBytes("")).toBe(0));
43
49
  test("invalid", () => expect(parseMemBytes("abc")).toBe(0));
50
+ test("does not match K before Ki (order-dependent regression guard)", () => {
51
+ expect(parseMemBytes("100Ki")).toBe(100 * 1024);
52
+ });
44
53
  });
45
54
 
46
55
  // ── formatCpu ────────────────────────────────────────────────────────────────
@@ -1,4 +1,5 @@
1
1
  import { formatAge } from "../k8s/k8s-format";
2
+ import { asRecord, asString } from "../k8s/resource-parser";
2
3
  import type { ResourceKind, ResourceRef } from "../k8s/types";
3
4
  import type { GenericListItem } from "./kubectl-types";
4
5
 
@@ -6,6 +7,12 @@ export function normalizeKind(kind: string): string {
6
7
  return kind.trim().toLowerCase();
7
8
  }
8
9
 
10
+ export const HELM_RELEASE_KIND = "helmreleases";
11
+
12
+ export function isHelmRelease(ref: { kind: string } | undefined): ref is { kind: string } {
13
+ return ref !== undefined && normalizeKind(ref.kind) === HELM_RELEASE_KIND;
14
+ }
15
+
9
16
  export function normalizeResourceTarget(ref: ResourceRef): string {
10
17
  const kind = normalizeKind(ref.kind);
11
18
  return `${kind}/${ref.name}`;
@@ -77,8 +84,8 @@ export function supportsScale(ref: ResourceRef): boolean {
77
84
  return ["deployment", "deployments", "statefulset", "statefulsets", "replicaset", "replicasets"].includes(kind);
78
85
  }
79
86
 
80
- export function parseKubectlError(stderr: string, args: string[]): Error {
81
- const message = stderr.trim() || `kubectl ${args.join(" ")} failed`;
87
+ export function parseKubectlError(stderr: string, args: string[], command = "kubectl"): Error {
88
+ const message = stderr.trim() || `${command} ${args.join(" ")} failed`;
82
89
  return new Error(message);
83
90
  }
84
91
 
@@ -114,9 +121,7 @@ export function resourceStatus(resource: GenericListItem): string {
114
121
  }
115
122
 
116
123
  if (kind === "rolebinding" || kind === "clusterrolebinding") {
117
- const subjects = Array.isArray((resource as unknown as Record<string, unknown>).subjects)
118
- ? ((resource as unknown as Record<string, unknown>).subjects as unknown[])
119
- : [];
124
+ const subjects = Array.isArray(resource.subjects) ? resource.subjects : [];
120
125
  return `${subjects.length} subjects`;
121
126
  }
122
127
 
@@ -164,9 +169,9 @@ export function resourceSummary(resource: GenericListItem): string {
164
169
  }
165
170
 
166
171
  if (kind === "node") {
167
- const nodeInfo = (status.nodeInfo ?? {}) as Record<string, unknown>;
168
- const kubeletVersion = typeof nodeInfo.kubeletVersion === "string" ? nodeInfo.kubeletVersion : "";
169
- const labels = (resource as unknown as { metadata?: { labels?: Record<string, string> } }).metadata?.labels ?? {};
172
+ const nodeInfo = asRecord(status.nodeInfo) ?? {};
173
+ const kubeletVersion = asString(nodeInfo.kubeletVersion) ?? "";
174
+ const labels = resource.metadata?.labels ?? {};
170
175
  const roles = Object.keys(labels)
171
176
  .filter((k) => k.startsWith("node-role.kubernetes.io/"))
172
177
  .map((k) => k.replace("node-role.kubernetes.io/", ""))
@@ -175,14 +180,14 @@ export function resourceSummary(resource: GenericListItem): string {
175
180
  }
176
181
 
177
182
  if (kind === "role" || kind === "clusterrole") {
178
- const rules = (resource as unknown as { rules?: unknown[] }).rules ?? [];
183
+ const rules = Array.isArray(resource.rules) ? resource.rules : [];
179
184
  return `${rules.length} rules`;
180
185
  }
181
186
 
182
187
  if (kind === "rolebinding" || kind === "clusterrolebinding") {
183
- const roleRef = (resource as unknown as { roleRef?: Record<string, unknown> }).roleRef;
188
+ const roleRef = resource.roleRef;
184
189
  if (roleRef) {
185
- return `\u2192 ${roleRef.kind ?? ""}/${roleRef.name ?? ""}`;
190
+ return `\u2192 ${asString(roleRef.kind) ?? ""}/${asString(roleRef.name) ?? ""}`;
186
191
  }
187
192
  return "";
188
193
  }