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
@@ -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
 
@@ -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,6 +1,6 @@
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
5
  export type InspectorTab = "summary" | "yaml" | "events" | "describe";
6
6
 
@@ -105,10 +105,18 @@ export interface AppError {
105
105
 
106
106
  export type NotificationTone = "info" | "success" | "warning" | "danger";
107
107
 
108
+ export interface NotificationAction {
109
+ id: string;
110
+ label: string;
111
+ }
112
+
108
113
  export interface Notification {
109
114
  id: string;
110
115
  tone: NotificationTone;
111
116
  message: string;
117
+ persistent: boolean;
118
+ createdAt: number;
119
+ actions: NotificationAction[];
112
120
  }
113
121
 
114
122
  export interface PodMetricEntry {
@@ -158,6 +166,7 @@ export interface AppState {
158
166
  revealedSecretValues: Record<string, string>;
159
167
  activePortForwards: ActivePortForward[];
160
168
  notifications: Notification[];
169
+ notificationHistory: Notification[];
161
170
  podMetrics: Record<string, { cpu: string; memory: string }>;
162
171
  nodeMetrics: Record<string, { cpu: string; memory: string; cpuPct: number; memPct: number }>;
163
172
  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
  }
@@ -84,6 +84,24 @@ export type {
84
84
 
85
85
  const KUBECTL_TIMEOUT_MS = 15_000;
86
86
 
87
+ interface NamespacedOptions {
88
+ context: string;
89
+ namespace?: string | undefined;
90
+ namespaced?: boolean | undefined;
91
+ }
92
+
93
+ function buildContextArgs(options: NamespacedOptions): string[] {
94
+ const args = ["--context", options.context];
95
+ if (options.namespaced && options.namespace !== undefined) {
96
+ args.push("-n", options.namespace);
97
+ }
98
+ return args;
99
+ }
100
+
101
+ function buildHelmBaseArgs(options: { context: string; namespace: string }): string[] {
102
+ return ["--kube-context", options.context, "-n", options.namespace];
103
+ }
104
+
87
105
  export class KubectlService {
88
106
  async isAvailable(): Promise<boolean> {
89
107
  try {
@@ -213,13 +231,19 @@ export class KubectlService {
213
231
  jsonArgs.push("-o", "json");
214
232
  yamlArgs.push("-o", "yaml");
215
233
 
216
- const [resource, yaml, describe] = await Promise.all([
234
+ const [resource, yaml, describeResult] = await Promise.allSettled([
217
235
  runJson<KubernetesResource>({ command: "kubectl", args: jsonArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
218
236
  runText({ command: "kubectl", args: yamlArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
219
- runText({ command: "kubectl", args: describeArgs, timeoutMs: KUBECTL_TIMEOUT_MS }).catch(() => ""),
237
+ runText({ command: "kubectl", args: describeArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
220
238
  ]);
221
239
 
222
- return buildResourceDetail({ resourceKind: options.resourceKind, resource, yaml, describe });
240
+ if (resource.status !== "fulfilled") {
241
+ throw resource.reason;
242
+ }
243
+ const yamlText = yaml.status === "fulfilled" ? yaml.value : "";
244
+ const describe = describeResult.status === "fulfilled" ? describeResult.value : "";
245
+
246
+ return buildResourceDetail({ resourceKind: options.resourceKind, resource: resource.value, yaml: yamlText, describe });
223
247
  }
224
248
 
225
249
  async listResourceEvents(options: ListResourceEventsOptions): Promise<EventItem[]> {
@@ -284,11 +308,7 @@ export class KubectlService {
284
308
  throw new Error(`Logs are not supported yet for ${options.resourceRef.kind}`);
285
309
  }
286
310
 
287
- const args = ["--context", options.context];
288
-
289
- if (options.namespaced) {
290
- args.push("-n", options.namespace);
291
- }
311
+ const args = buildContextArgs(options);
292
312
 
293
313
  const tail = options.logOptions?.tail ?? 120;
294
314
  const since = options.logOptions?.since;
@@ -307,7 +327,7 @@ export class KubectlService {
307
327
  }
308
328
 
309
329
  if (logTarget.includeAllPods) {
310
- args.push("--all-pods=true");
330
+ args.push("--all-containers=true");
311
331
  }
312
332
 
313
333
  if (options.container) {
@@ -325,11 +345,7 @@ export class KubectlService {
325
345
  throw new Error(`Scale is not supported yet for ${options.resourceRef.kind}`);
326
346
  }
327
347
 
328
- const args = ["--context", options.context];
329
-
330
- if (options.namespaced) {
331
- args.push("-n", options.namespace);
332
- }
348
+ const args = buildContextArgs(options);
333
349
 
334
350
  args.push("scale", normalizeResourceTarget(options.resourceRef), `--replicas=${options.replicas}`);
335
351
 
@@ -341,11 +357,7 @@ export class KubectlService {
341
357
  throw new Error(`Rollout restart is not supported yet for ${options.resourceRef.kind}`);
342
358
  }
343
359
 
344
- const args = ["--context", options.context];
345
-
346
- if (options.namespaced) {
347
- args.push("-n", options.namespace);
348
- }
360
+ const args = buildContextArgs(options);
349
361
 
350
362
  args.push("rollout", "restart", normalizeResourceTarget(options.resourceRef));
351
363
 
@@ -357,11 +369,7 @@ export class KubectlService {
357
369
  return this.uninstallHelmRelease(options);
358
370
  }
359
371
 
360
- const args = ["--context", options.context];
361
-
362
- if (options.namespaced) {
363
- args.push("-n", options.namespace);
364
- }
372
+ const args = buildContextArgs(options);
365
373
 
366
374
  args.push("delete", normalizeResourceTarget(options.resourceRef));
367
375
 
@@ -369,26 +377,18 @@ export class KubectlService {
369
377
  }
370
378
 
371
379
  startShell(options: StartShellOptions): ChildProcess {
372
- const args = ["--context", options.context];
373
-
374
- if (options.namespaced) {
375
- args.push("-n", options.namespace);
376
- }
380
+ const args = buildContextArgs(options);
377
381
 
378
382
  const kind = normalizeKind(options.resourceRef.kind);
379
383
  const target = kind === "pod" || kind === "pods" ? `pod/${options.resourceRef.name}` : normalizeResourceTarget(options.resourceRef);
380
384
 
381
385
  args.push("exec", "-it", target, "--", "sh", "-lc", "exec ${SHELL:-/bin/sh}");
382
386
 
383
- return startPersistentProcess({ command: "kubectl", args, stdio: "inherit" });
387
+ return startPersistentProcess({ command: "kubectl", args, stdio: ["inherit", "inherit", "pipe"] });
384
388
  }
385
389
 
386
390
  editResource(options: EditResourceOptions): ChildProcess {
387
- const args = ["--context", options.context];
388
-
389
- if (options.namespaced) {
390
- args.push("-n", options.namespace);
391
- }
391
+ const args = buildContextArgs(options);
392
392
 
393
393
  args.push("edit", normalizeResourceTarget(options.resourceRef));
394
394
 
@@ -529,7 +529,7 @@ export class KubectlService {
529
529
  const parsed = await runJson<SecretResponse>({ command: "kubectl", args, timeoutMs: KUBECTL_TIMEOUT_MS });
530
530
  const encoded = parsed.data?.[options.key];
531
531
 
532
- if (!encoded) {
532
+ if (encoded === undefined) {
533
533
  throw new Error(`Key "${options.key}" not found in secret "${options.name}"`);
534
534
  }
535
535
 
@@ -547,7 +547,7 @@ export class KubectlService {
547
547
  }
548
548
 
549
549
  private async listHelmReleases(options: ListResourcesOptions): Promise<ResourceListItem[]> {
550
- const args = ["list", "--kube-context", options.context, "-n", options.namespace, "-o", "json"];
550
+ const args = ["list", ...buildHelmBaseArgs(options), "-o", "json"];
551
551
  const result = await runCommand({ command: "helm", args, timeoutMs: KUBECTL_TIMEOUT_MS });
552
552
  const items = JSON.parse(result.stdout) as HelmListItem[];
553
553
 
@@ -564,7 +564,7 @@ export class KubectlService {
564
564
  }
565
565
 
566
566
  private async getHelmReleaseDetail(options: GetResourceDetailOptions): Promise<ResourceDetail> {
567
- const baseArgs = ["--kube-context", options.context, "-n", options.namespace];
567
+ const baseArgs = buildHelmBaseArgs(options);
568
568
 
569
569
  const [statusResult, valuesResult] = await Promise.all([
570
570
  runCommand({ command: "helm", args: ["status", options.name, ...baseArgs, "-o", "json"], timeoutMs: KUBECTL_TIMEOUT_MS }),
@@ -621,7 +621,7 @@ export class KubectlService {
621
621
  private async uninstallHelmRelease(options: DeleteResourceOptions): Promise<void> {
622
622
  await runCommand({
623
623
  command: "helm",
624
- args: ["uninstall", options.resourceRef.name, "--kube-context", options.context, "-n", options.namespace],
624
+ args: ["uninstall", options.resourceRef.name, ...buildHelmBaseArgs(options)],
625
625
  timeoutMs: KUBECTL_TIMEOUT_MS,
626
626
  });
627
627
  }
@@ -142,14 +142,23 @@ export interface GenericListItem {
142
142
  name?: string | undefined;
143
143
  namespace?: string | undefined;
144
144
  creationTimestamp?: string | undefined;
145
+ labels?: Record<string, string> | undefined;
145
146
  } | undefined;
146
147
  spec?: Record<string, unknown> | undefined;
147
148
  status?: Record<string, unknown> | undefined;
149
+ // Event-only fields
148
150
  type?: string | undefined;
149
151
  reason?: string | undefined;
150
152
  message?: string | undefined;
151
153
  lastTimestamp?: string | undefined;
152
154
  firstTimestamp?: string | undefined;
155
+ // RBAC fields
156
+ rules?: unknown[] | undefined;
157
+ roleRef?: Record<string, unknown> | undefined;
158
+ subjects?: unknown[] | undefined;
159
+ // ServiceAccount fields
160
+ secrets?: unknown[] | undefined;
161
+ imagePullSecrets?: unknown[] | undefined;
153
162
  }
154
163
 
155
164
  export interface GenericListResponse {
@@ -218,7 +227,7 @@ export interface HelmStatusResponse {
218
227
  }
219
228
 
220
229
  export interface ApiResourcesResponse {
221
- resources: ApiResourceItem[];
230
+ resources?: ApiResourceItem[] | undefined;
222
231
  }
223
232
 
224
233
  export interface ApiResourceItem {
@@ -8,16 +8,30 @@ export function parseCpuNano(value: string): number {
8
8
 
9
9
  export function parseMemBytes(value: string): number {
10
10
  if (!value) return 0;
11
- const units: Record<string, number> = {
12
- Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4,
13
- K: 1000, M: 1000 ** 2, G: 1000 ** 3, T: 1000 ** 4,
14
- };
15
- for (const [suffix, mult] of Object.entries(units)) {
11
+ // Order matters: multi-character (binary) suffixes must be checked before their
12
+ // single-letter decimal counterparts (e.g. "Ki" before "K").
13
+ const units: Array<[string, number]> = [
14
+ ["Ei", 1024 ** 6],
15
+ ["Pi", 1024 ** 5],
16
+ ["Ti", 1024 ** 4],
17
+ ["Gi", 1024 ** 3],
18
+ ["Mi", 1024 ** 2],
19
+ ["Ki", 1024],
20
+ ["E", 1000 ** 6],
21
+ ["P", 1000 ** 5],
22
+ ["T", 1000 ** 4],
23
+ ["G", 1000 ** 3],
24
+ ["M", 1000 ** 2],
25
+ ["K", 1000],
26
+ ];
27
+ for (const [suffix, mult] of units) {
16
28
  if (value.endsWith(suffix)) {
17
- return Number.parseFloat(value) * mult;
29
+ const n = Number.parseFloat(value);
30
+ return Number.isFinite(n) ? n * mult : 0;
18
31
  }
19
32
  }
20
- return Number.parseFloat(value) || 0;
33
+ const n = Number.parseFloat(value);
34
+ return Number.isFinite(n) ? n : 0;
21
35
  }
22
36
 
23
37
  export function formatCpu(nano: number): string {