openk8s 0.0.1 → 1.0.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 (35) hide show
  1. package/README.md +194 -40
  2. package/bin/openk8s.js +2 -0
  3. package/package.json +52 -6
  4. package/src/app/app-state.ts +461 -0
  5. package/src/app/app.tsx +708 -0
  6. package/src/app/components/inspector.tsx +449 -0
  7. package/src/app/components/kind-rows.tsx +66 -0
  8. package/src/app/components/notification-tray.tsx +59 -0
  9. package/src/app/components/overlays/delete-confirm-overlay.tsx +79 -0
  10. package/src/app/components/overlays/helm-rollback-overlay.tsx +86 -0
  11. package/src/app/components/overlays/index.ts +12 -0
  12. package/src/app/components/overlays/logs-dialog.tsx +303 -0
  13. package/src/app/components/overlays/port-forward-overlay.tsx +184 -0
  14. package/src/app/components/overlays/scale-dialog.tsx +96 -0
  15. package/src/app/components/overlays/select-overlay.tsx +68 -0
  16. package/src/app/components/overlays/shared.tsx +18 -0
  17. package/src/app/components/port-forwards-tray.tsx +57 -0
  18. package/src/app/components/resource-rows.tsx +120 -0
  19. package/src/app/hooks/use-app-keyboard.ts +723 -0
  20. package/src/app/hooks/use-app-side-effects.ts +39 -0
  21. package/src/app/hooks/use-clipboard.ts +54 -0
  22. package/src/app/hooks/use-data-fetching.ts +366 -0
  23. package/src/app/hooks/use-log-stream.ts +113 -0
  24. package/src/app/hooks/use-port-forward.ts +149 -0
  25. package/src/app/persistence.ts +44 -0
  26. package/src/app/theme.ts +95 -0
  27. package/src/app/use-polling-tick.ts +27 -0
  28. package/src/app/utils.ts +274 -0
  29. package/src/index.tsx +8 -0
  30. package/src/lib/k8s/k8s-format.ts +42 -0
  31. package/src/lib/k8s/resource-detail-builder.ts +545 -0
  32. package/src/lib/k8s/resource-parser.ts +308 -0
  33. package/src/lib/k8s/types.ts +164 -0
  34. package/src/lib/kubectl/kubectl-service.ts +1116 -0
  35. package/src/lib/kubectl/spawn-utils.ts +81 -0
@@ -0,0 +1,1116 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+
3
+ import { formatAge } from "../k8s/k8s-format";
4
+ import { buildResourceDetail, type KubernetesResource } from "../k8s/resource-detail-builder";
5
+ import type {
6
+ ClusterContext,
7
+ EventItem,
8
+ LogOptions,
9
+ NamespaceItem,
10
+ ResourceDetail,
11
+ ResourceKind,
12
+ ResourceListItem,
13
+ ResourceRef,
14
+ } from "../k8s/types";
15
+ import { runCommand, runJson, runText, startPersistentProcess } from "./spawn-utils";
16
+
17
+ const KUBECTL_TIMEOUT_MS = 15_000;
18
+
19
+ export class KubectlService {
20
+ async isAvailable(): Promise<boolean> {
21
+ try {
22
+ await runCommand({ command: "kubectl", args: ["version", "--client"], timeoutMs: KUBECTL_TIMEOUT_MS });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ async isHelmAvailable(): Promise<boolean> {
30
+ try {
31
+ await runCommand({ command: "helm", args: ["version", "--short"], timeoutMs: KUBECTL_TIMEOUT_MS });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ async listContexts(): Promise<ClusterContext[]> {
39
+ const parsed = await runJson<KubeConfigContextResponse>({
40
+ command: "kubectl",
41
+ args: ["config", "view", "-o", "json"],
42
+ timeoutMs: KUBECTL_TIMEOUT_MS,
43
+ });
44
+ const current = parsed["current-context"];
45
+
46
+ return (parsed.contexts ?? [])
47
+ .map((context) => context.name)
48
+ .filter((name): name is string => Boolean(name))
49
+ .map((name) => ({ name, isCurrent: name === current }));
50
+ }
51
+
52
+ async listNamespaces(options: ListNamespacesOptions): Promise<NamespaceItem[]> {
53
+ const parsed = await runJson<NamespaceListResponse>({
54
+ command: "kubectl",
55
+ args: ["--context", options.context, "get", "namespaces", "-o", "json"],
56
+ timeoutMs: KUBECTL_TIMEOUT_MS,
57
+ });
58
+
59
+ return (parsed.items ?? [])
60
+ .map((item) => item.metadata?.name)
61
+ .filter((name): name is string => Boolean(name))
62
+ .map((name) => ({ name }));
63
+ }
64
+
65
+ async listResourceKinds(options: ListResourceKindsOptions): Promise<ResourceKind[]> {
66
+ const [result, helmAvailable] = await Promise.all([
67
+ runCommand({ command: "kubectl", args: ["--context", options.context, "api-resources", "--cached=false", "-o", "wide"], timeoutMs: KUBECTL_TIMEOUT_MS }),
68
+ this.isHelmAvailable(),
69
+ ]);
70
+
71
+ const lines = result.stdout
72
+ .split("\n")
73
+ .map((line) => line.trimEnd())
74
+ .filter(Boolean);
75
+
76
+ const dataLines = lines.slice(1);
77
+ const parsed = dataLines
78
+ .map((line): ApiResourcesLine | undefined => {
79
+ const columns = line.split(/\s{2,}/).map((part) => part.trim());
80
+ const [name, shortNamesText = "", groupText = "", namespacedText = "", kindText = ""] = columns;
81
+
82
+ if (columns.length < 5 || !name || !namespacedText || !kindText) {
83
+ return undefined;
84
+ }
85
+
86
+ return {
87
+ name,
88
+ shortNames: shortNamesText === "<none>" ? [] : shortNamesText.split(","),
89
+ group: groupText === "<none>" ? "" : groupText,
90
+ namespaced: namespacedText === "true",
91
+ kind: kindText,
92
+ };
93
+ })
94
+ .filter((value): value is ApiResourcesLine => value !== undefined)
95
+ .map((value) => ({
96
+ name: value.name,
97
+ namespaced: value.namespaced,
98
+ group: value.group || undefined,
99
+ shortNames: value.shortNames,
100
+ }));
101
+
102
+ const curated = curatedKinds(parsed);
103
+
104
+ if (helmAvailable) {
105
+ curated.push({ name: "helmreleases", namespaced: true, shortNames: ["hr"] });
106
+ }
107
+
108
+ return curated;
109
+ }
110
+
111
+ async listResources(options: ListResourcesOptions): Promise<ResourceListItem[]> {
112
+ if (options.resourceKind.name === "helmreleases") {
113
+ return this.listHelmReleases(options);
114
+ }
115
+
116
+ const isEvents = options.resourceKind.name === "events";
117
+
118
+ const args = ["--context", options.context, "get", options.resourceKind.name];
119
+
120
+ if (isEvents) {
121
+ // Always fetch all namespaces for events, sorted by time
122
+ args.push("-A");
123
+ } else if (options.resourceKind.namespaced) {
124
+ args.push("-n", options.namespace);
125
+ }
126
+
127
+ args.push("-o", "json");
128
+
129
+ const parsed = await runJson<GenericListResponse>({ command: "kubectl", args, timeoutMs: KUBECTL_TIMEOUT_MS });
130
+
131
+ let items = parsed.items ?? [];
132
+
133
+ if (isEvents) {
134
+ items = items
135
+ .sort((a, b) => {
136
+ const aTime = a.lastTimestamp ?? a.firstTimestamp ?? a.metadata?.creationTimestamp ?? "";
137
+ const bTime = b.lastTimestamp ?? b.firstTimestamp ?? b.metadata?.creationTimestamp ?? "";
138
+ return bTime.localeCompare(aTime);
139
+ })
140
+ .slice(0, 200);
141
+ }
142
+
143
+ return items.map((item) => ({
144
+ ref: {
145
+ kind: item.kind ?? options.resourceKind.name,
146
+ name: item.metadata?.name ?? "unknown",
147
+ namespace: item.metadata?.namespace,
148
+ },
149
+ status: resourceStatus(item),
150
+ age: formatAge(item.lastTimestamp ?? item.firstTimestamp ?? item.metadata?.creationTimestamp),
151
+ summary: resourceSummary(item),
152
+ }));
153
+ }
154
+
155
+ async getResourceDetail(options: GetResourceDetailOptions): Promise<ResourceDetail> {
156
+ if (options.resourceKind.name === "helmreleases") {
157
+ return this.getHelmReleaseDetail(options);
158
+ }
159
+
160
+ const jsonArgs = ["--context", options.context, "get", options.resourceKind.name, options.name];
161
+ const yamlArgs = ["--context", options.context, "get", options.resourceKind.name, options.name];
162
+ const describeArgs = ["--context", options.context, "describe", options.resourceKind.name, options.name];
163
+
164
+ if (options.resourceKind.namespaced) {
165
+ jsonArgs.push("-n", options.namespace);
166
+ yamlArgs.push("-n", options.namespace);
167
+ describeArgs.push("-n", options.namespace);
168
+ }
169
+
170
+ jsonArgs.push("-o", "json");
171
+ yamlArgs.push("-o", "yaml");
172
+
173
+ const [resource, yaml, describe] = await Promise.all([
174
+ runJson<KubernetesResource>({ command: "kubectl", args: jsonArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
175
+ runText({ command: "kubectl", args: yamlArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
176
+ runText({ command: "kubectl", args: describeArgs, timeoutMs: KUBECTL_TIMEOUT_MS }).catch(() => ""),
177
+ ]);
178
+
179
+ return buildResourceDetail({ resourceKind: options.resourceKind, resource, yaml, describe });
180
+ }
181
+
182
+ async listResourceEvents(options: ListResourceEventsOptions): Promise<EventItem[]> {
183
+ const args = ["--context", options.context, "get", "events"];
184
+
185
+ if (options.namespaced) {
186
+ args.push("-n", options.namespace);
187
+ } else {
188
+ args.push("-A");
189
+ }
190
+
191
+ args.push("-o", "json");
192
+
193
+ const parsed = await runJson<EventListResponse>({ command: "kubectl", args, timeoutMs: KUBECTL_TIMEOUT_MS });
194
+ const targetKind = normalizeKind(options.resourceRef.kind);
195
+
196
+ return (parsed.items ?? [])
197
+ .filter((item) => {
198
+ const involved = item.involvedObject;
199
+
200
+ if (!involved?.name || !involved.kind) {
201
+ return false;
202
+ }
203
+
204
+ if (normalizeKind(involved.kind) !== targetKind) {
205
+ return false;
206
+ }
207
+
208
+ if (involved.name !== options.resourceRef.name) {
209
+ return false;
210
+ }
211
+
212
+ if (
213
+ options.resourceRef.namespace &&
214
+ involved.namespace &&
215
+ involved.namespace !== options.resourceRef.namespace
216
+ ) {
217
+ return false;
218
+ }
219
+
220
+ return true;
221
+ })
222
+ .sort((left, right) => {
223
+ const leftTime = left.lastTimestamp ?? left.firstTimestamp ?? left.metadata?.creationTimestamp ?? "";
224
+ const rightTime = right.lastTimestamp ?? right.firstTimestamp ?? right.metadata?.creationTimestamp ?? "";
225
+ return rightTime.localeCompare(leftTime);
226
+ })
227
+ .slice(0, 50)
228
+ .map((item) => ({
229
+ type: item.type ?? "Normal",
230
+ reason: item.reason ?? "Unknown",
231
+ message: item.message ?? "",
232
+ age: formatAge(item.lastTimestamp ?? item.firstTimestamp ?? item.metadata?.creationTimestamp),
233
+ source: item.reportingComponent ?? item.source?.component ?? item.source?.host ?? "cluster",
234
+ }));
235
+ }
236
+
237
+ streamLogs(options: StreamLogsOptions): StreamLogsResult {
238
+ const logTarget = normalizeLogTarget(options.resourceRef);
239
+
240
+ if (!logTarget) {
241
+ throw new Error(`Logs are not supported yet for ${options.resourceRef.kind}`);
242
+ }
243
+
244
+ const args = ["--context", options.context];
245
+
246
+ if (options.namespaced) {
247
+ args.push("-n", options.namespace);
248
+ }
249
+
250
+ const tail = options.logOptions?.tail ?? 120;
251
+ const since = options.logOptions?.since;
252
+ const previous = options.logOptions?.previous ?? false;
253
+
254
+ args.push("logs", logTarget.target, `--tail=${tail}`);
255
+
256
+ if (since) {
257
+ args.push(`--since=${since}`);
258
+ }
259
+
260
+ if (previous) {
261
+ args.push("--previous=true");
262
+ } else {
263
+ args.push("--follow=true");
264
+ }
265
+
266
+ if (logTarget.includeAllPods) {
267
+ args.push("--all-pods=true");
268
+ }
269
+
270
+ if (options.container) {
271
+ args.push(`--container=${options.container}`);
272
+ }
273
+
274
+ return {
275
+ target: logTarget.target,
276
+ child: startPersistentProcess({ command: "kubectl", args, stdio: ["ignore", "pipe", "pipe"] }),
277
+ };
278
+ }
279
+
280
+ async scaleResource(options: ScaleResourceOptions): Promise<void> {
281
+ if (!supportsScale(options.resourceRef)) {
282
+ throw new Error(`Scale is not supported yet for ${options.resourceRef.kind}`);
283
+ }
284
+
285
+ const args = ["--context", options.context];
286
+
287
+ if (options.namespaced) {
288
+ args.push("-n", options.namespace);
289
+ }
290
+
291
+ args.push("scale", normalizeResourceTarget(options.resourceRef), `--replicas=${options.replicas}`);
292
+
293
+ await runText({ command: "kubectl", args, timeoutMs: KUBECTL_TIMEOUT_MS });
294
+ }
295
+
296
+ async rolloutRestart(options: RolloutRestartOptions): Promise<void> {
297
+ if (!supportsRolloutRestart(options.resourceRef)) {
298
+ throw new Error(`Rollout restart is not supported yet for ${options.resourceRef.kind}`);
299
+ }
300
+
301
+ const args = ["--context", options.context];
302
+
303
+ if (options.namespaced) {
304
+ args.push("-n", options.namespace);
305
+ }
306
+
307
+ args.push("rollout", "restart", normalizeResourceTarget(options.resourceRef));
308
+
309
+ await runText({ command: "kubectl", args, timeoutMs: KUBECTL_TIMEOUT_MS });
310
+ }
311
+
312
+ async deleteResource(options: DeleteResourceOptions): Promise<void> {
313
+ if (normalizeKind(options.resourceRef.kind) === "helmreleases") {
314
+ return this.uninstallHelmRelease(options);
315
+ }
316
+
317
+ const args = ["--context", options.context];
318
+
319
+ if (options.namespaced) {
320
+ args.push("-n", options.namespace);
321
+ }
322
+
323
+ args.push("delete", normalizeResourceTarget(options.resourceRef));
324
+
325
+ await runText({ command: "kubectl", args, timeoutMs: KUBECTL_TIMEOUT_MS });
326
+ }
327
+
328
+ startShell(options: StartShellOptions): ChildProcess {
329
+ const args = ["--context", options.context];
330
+
331
+ if (options.namespaced) {
332
+ args.push("-n", options.namespace);
333
+ }
334
+
335
+ // pod/name is the canonical exec target; other workload kinds are also accepted by kubectl exec
336
+ const kind = normalizeKind(options.resourceRef.kind);
337
+ const target = kind === "pod" || kind === "pods" ? `pod/${options.resourceRef.name}` : normalizeResourceTarget(options.resourceRef);
338
+
339
+ // sh -lc lets $SHELL expand inside the child shell, not this process
340
+ args.push("exec", "-it", target, "--", "sh", "-lc", "exec ${SHELL:-/bin/sh}");
341
+
342
+ return startPersistentProcess({ command: "kubectl", args, stdio: "inherit" });
343
+ }
344
+
345
+ editResource(options: EditResourceOptions): ChildProcess {
346
+ const args = ["--context", options.context];
347
+
348
+ if (options.namespaced) {
349
+ args.push("-n", options.namespace);
350
+ }
351
+
352
+ args.push("edit", normalizeResourceTarget(options.resourceRef));
353
+
354
+ return startPersistentProcess({ command: "kubectl", args, stdio: "inherit" });
355
+ }
356
+
357
+ async helmUpgradeValues(options: HelmUpgradeValuesOptions): Promise<void> {
358
+ await runCommand({
359
+ command: "helm",
360
+ args: [
361
+ "upgrade",
362
+ options.name,
363
+ options.chart,
364
+ "--reuse-values",
365
+ "--values",
366
+ options.valuesFile,
367
+ "--kube-context",
368
+ options.context,
369
+ "-n",
370
+ options.namespace,
371
+ ],
372
+ timeoutMs: KUBECTL_TIMEOUT_MS,
373
+ });
374
+ }
375
+
376
+ async getPodMetrics(options: { context: string; namespace: string }): Promise<PodMetricsMap> {
377
+ try {
378
+ const raw = await runText({
379
+ command: "kubectl",
380
+ args: [
381
+ "--context", options.context,
382
+ "get", "--raw",
383
+ `/apis/metrics.k8s.io/v1beta1/namespaces/${options.namespace}/pods`,
384
+ ],
385
+ timeoutMs: KUBECTL_TIMEOUT_MS,
386
+ });
387
+ const parsed = JSON.parse(raw) as { items?: PodMetricsItem[] };
388
+ const result: PodMetricsMap = {};
389
+ for (const item of parsed.items ?? []) {
390
+ const name = item.metadata?.name;
391
+ if (!name) continue;
392
+ const cpuNano = item.containers?.reduce((acc, c) => acc + parseCpuNano(c.usage?.cpu ?? "0"), 0) ?? 0;
393
+ const memBytes = item.containers?.reduce((acc, c) => acc + parseMemBytes(c.usage?.memory ?? "0"), 0) ?? 0;
394
+ result[name] = { cpu: formatCpu(cpuNano), memory: formatMem(memBytes) };
395
+ }
396
+ return result;
397
+ } catch {
398
+ return {};
399
+ }
400
+ }
401
+
402
+ async getNodeMetrics(options: { context: string }): Promise<NodeMetricsMap> {
403
+ try {
404
+ const [metricsRaw, nodesRaw] = await Promise.all([
405
+ runText({ command: "kubectl", args: ["--context", options.context, "get", "--raw", "/apis/metrics.k8s.io/v1beta1/nodes"], timeoutMs: KUBECTL_TIMEOUT_MS }),
406
+ runText({ command: "kubectl", args: ["--context", options.context, "get", "nodes", "-o", "json"], timeoutMs: KUBECTL_TIMEOUT_MS }),
407
+ ]);
408
+ const metricsData = JSON.parse(metricsRaw) as { items?: NodeMetricsItem[] };
409
+ const nodesData = JSON.parse(nodesRaw) as { items?: Array<{ metadata?: { name?: string }; status?: { allocatable?: { cpu?: string; memory?: string } } }> };
410
+
411
+ const allocatable = new Map<string, { cpu: number; memory: number }>();
412
+ for (const node of nodesData.items ?? []) {
413
+ const name = node.metadata?.name;
414
+ if (!name) continue;
415
+ const cpu = parseCpuNano(node.status?.allocatable?.cpu ?? "0");
416
+ const mem = parseMemBytes(node.status?.allocatable?.memory ?? "0");
417
+ allocatable.set(name, { cpu, memory: mem });
418
+ }
419
+
420
+ const result: NodeMetricsMap = {};
421
+ for (const item of metricsData.items ?? []) {
422
+ const name = item.metadata?.name;
423
+ if (!name) continue;
424
+ const cpuNano = parseCpuNano(item.usage?.cpu ?? "0");
425
+ const memBytes = parseMemBytes(item.usage?.memory ?? "0");
426
+ const alloc = allocatable.get(name);
427
+ const cpuPct = alloc && alloc.cpu > 0 ? Math.round((cpuNano / alloc.cpu) * 100) : 0;
428
+ const memPct = alloc && alloc.memory > 0 ? Math.round((memBytes / alloc.memory) * 100) : 0;
429
+ result[name] = { cpu: formatCpu(cpuNano), memory: formatMem(memBytes), cpuPct, memPct };
430
+ }
431
+ return result;
432
+ } catch {
433
+ return {};
434
+ }
435
+ }
436
+
437
+ async helmRollback(options: HelmRollbackOptions): Promise<void> {
438
+ const args = [
439
+ "rollback",
440
+ options.name,
441
+ ...(options.revision !== undefined ? [String(options.revision)] : []),
442
+ "--kube-context", options.context,
443
+ "-n", options.namespace,
444
+ ];
445
+ await runCommand({ command: "helm", args, timeoutMs: KUBECTL_TIMEOUT_MS });
446
+ }
447
+
448
+ helmUpgrade(options: HelmUpgradeOptions): ChildProcess {
449
+ const args = [
450
+ "upgrade",
451
+ options.name,
452
+ options.chart,
453
+ "--reuse-values",
454
+ "--kube-context", options.context,
455
+ "-n", options.namespace,
456
+ ];
457
+ return startPersistentProcess({ command: "helm", args, stdio: "inherit" });
458
+ }
459
+
460
+ startPortForward(options: StartPortForwardOptions): ChildProcess {
461
+ const target = normalizePortForwardTarget(options.resourceRef);
462
+
463
+ if (!target) {
464
+ throw new Error(`Port forward is not supported yet for ${options.resourceRef.kind}`);
465
+ }
466
+
467
+ const args = ["--context", options.context];
468
+
469
+ if (options.namespaced) {
470
+ args.push("-n", options.namespace);
471
+ }
472
+
473
+ args.push("port-forward", target, `${options.localPort}:${options.remotePort}`);
474
+
475
+ return startPersistentProcess({ command: "kubectl", args, stdio: ["ignore", "pipe", "pipe"] });
476
+ }
477
+
478
+ /** Returns true if kubectl scale is supported for the given resource kind. */
479
+ canScale(ref: ResourceRef): boolean {
480
+ return supportsScale(ref);
481
+ }
482
+
483
+ /** Returns true if kubectl rollout restart is supported for the given resource kind. */
484
+ canRolloutRestart(ref: ResourceRef): boolean {
485
+ return supportsRolloutRestart(ref);
486
+ }
487
+
488
+ /** Fetches and base64-decodes a single key from a Kubernetes Secret. */
489
+ async getSecretValue(options: GetSecretValueOptions): Promise<string> {
490
+ const args = ["--context", options.context, "-n", options.namespace, "get", "secret", options.name, "-o", "json"];
491
+ const parsed = await runJson<SecretResponse>({ command: "kubectl", args, timeoutMs: KUBECTL_TIMEOUT_MS });
492
+ const encoded = parsed.data?.[options.key];
493
+
494
+ if (!encoded) {
495
+ throw new Error(`Key "${options.key}" not found in secret "${options.name}"`);
496
+ }
497
+
498
+ return Buffer.from(encoded, "base64").toString("utf8");
499
+ }
500
+
501
+ /** Fetches and base64-decodes all keys from a Kubernetes Secret. */
502
+ async getAllSecretValues(options: GetAllSecretValuesOptions): Promise<Record<string, string>> {
503
+ const args = ["--context", options.context, "-n", options.namespace, "get", "secret", options.name, "-o", "json"];
504
+ const parsed = await runJson<SecretResponse>({ command: "kubectl", args, timeoutMs: KUBECTL_TIMEOUT_MS });
505
+ const data = parsed.data ?? {};
506
+
507
+ return Object.fromEntries(
508
+ Object.entries(data).map(([key, encoded]) => [key, Buffer.from(encoded, "base64").toString("utf8")]),
509
+ );
510
+ }
511
+
512
+ private async listHelmReleases(options: ListResourcesOptions): Promise<ResourceListItem[]> {
513
+ const args = ["list", "--kube-context", options.context, "-n", options.namespace, "-o", "json"];
514
+ const result = await runCommand({ command: "helm", args, timeoutMs: KUBECTL_TIMEOUT_MS });
515
+ const items = JSON.parse(result.stdout) as HelmListItem[];
516
+
517
+ return items.map((item) => ({
518
+ ref: {
519
+ kind: "helmreleases",
520
+ name: item.name,
521
+ namespace: item.namespace,
522
+ },
523
+ status: item.status,
524
+ age: formatAge(item.updated),
525
+ summary: item.chart,
526
+ }));
527
+ }
528
+
529
+ private async getHelmReleaseDetail(options: GetResourceDetailOptions): Promise<ResourceDetail> {
530
+ const baseArgs = ["--kube-context", options.context, "-n", options.namespace];
531
+
532
+ const [statusResult, valuesResult] = await Promise.all([
533
+ runCommand({ command: "helm", args: ["status", options.name, ...baseArgs, "-o", "json"], timeoutMs: KUBECTL_TIMEOUT_MS }),
534
+ runCommand({ command: "helm", args: ["get", "values", options.name, ...baseArgs], timeoutMs: KUBECTL_TIMEOUT_MS }),
535
+ ]);
536
+
537
+ const status = JSON.parse(statusResult.stdout) as HelmStatusResponse;
538
+ const yaml = valuesResult.stdout;
539
+
540
+ const overviewLines: string[] = [
541
+ `name: ${status.name}`,
542
+ `namespace: ${status.namespace}`,
543
+ `chart: ${status.chart.metadata.name}`,
544
+ `chartVersion: ${status.chart.metadata.version}`,
545
+ ...(status.chart.metadata.appVersion ? [`appVersion: ${status.chart.metadata.appVersion}`] : []),
546
+ `revision: ${status.version}`,
547
+ `status: ${status.info.status}`,
548
+ `firstDeployed: ${status.info.first_deployed}`,
549
+ `lastDeployed: ${status.info.last_deployed}`,
550
+ ...(status.info.description ? [`description: ${status.info.description}`] : []),
551
+ ];
552
+
553
+ const notesLines =
554
+ status.info.notes
555
+ ?.split("\n")
556
+ .map((line) => line.trimEnd())
557
+ .filter((line) => line.length > 0) ?? [];
558
+
559
+ const summarySections = [
560
+ { title: "Overview", lines: overviewLines },
561
+ ...(notesLines.length > 0 ? [{ title: "Notes", lines: notesLines }] : []),
562
+ ];
563
+
564
+ const summaryLines = summarySections.flatMap((section) => [
565
+ section.title,
566
+ ...section.lines.map((line) => ` ${line}`),
567
+ ]);
568
+
569
+ return {
570
+ ref: {
571
+ kind: "helmreleases",
572
+ name: status.name,
573
+ namespace: status.namespace,
574
+ },
575
+ summaryLines,
576
+ summarySections,
577
+ yaml,
578
+ replicas: undefined,
579
+ portForwards: undefined,
580
+ helmChart: status.chart.metadata.name,
581
+ };
582
+ }
583
+
584
+ private async uninstallHelmRelease(options: DeleteResourceOptions): Promise<void> {
585
+ await runCommand({
586
+ command: "helm",
587
+ args: ["uninstall", options.resourceRef.name, "--kube-context", options.context, "-n", options.namespace],
588
+ timeoutMs: KUBECTL_TIMEOUT_MS,
589
+ });
590
+ }
591
+
592
+
593
+ }
594
+
595
+ export interface ListNamespacesOptions {
596
+ context: string;
597
+ }
598
+
599
+ export interface ListResourceKindsOptions {
600
+ context: string;
601
+ }
602
+
603
+ export interface ListResourcesOptions {
604
+ context: string;
605
+ namespace: string;
606
+ resourceKind: ResourceKind;
607
+ }
608
+
609
+ export interface GetResourceDetailOptions {
610
+ context: string;
611
+ namespace: string;
612
+ resourceKind: ResourceKind;
613
+ name: string;
614
+ }
615
+
616
+ export interface ListResourceEventsOptions {
617
+ context: string;
618
+ namespace: string;
619
+ resourceRef: ResourceRef;
620
+ namespaced: boolean;
621
+ }
622
+
623
+ export interface EditResourceOptions {
624
+ context: string;
625
+ namespace: string;
626
+ resourceRef: ResourceRef;
627
+ namespaced: boolean;
628
+ }
629
+
630
+ export interface HelmUpgradeValuesOptions {
631
+ context: string;
632
+ namespace: string;
633
+ name: string;
634
+ chart: string;
635
+ valuesFile: string;
636
+ }
637
+
638
+ export interface HelmRollbackOptions {
639
+ context: string;
640
+ namespace: string;
641
+ name: string;
642
+ revision?: number | undefined;
643
+ }
644
+
645
+ export interface HelmUpgradeOptions {
646
+ context: string;
647
+ namespace: string;
648
+ name: string;
649
+ chart: string;
650
+ }
651
+
652
+ export type PodMetricsMap = Record<string, { cpu: string; memory: string }>;
653
+ export type NodeMetricsMap = Record<string, { cpu: string; memory: string; cpuPct: number; memPct: number }>;
654
+
655
+ export interface StreamLogsOptions {
656
+ context: string;
657
+ namespace: string;
658
+ resourceRef: ResourceRef;
659
+ namespaced: boolean;
660
+ container?: string | undefined;
661
+ logOptions?: LogOptions | undefined;
662
+ }
663
+
664
+ export interface StreamLogsResult {
665
+ target: string;
666
+ child: ChildProcess;
667
+ }
668
+
669
+ export interface ScaleResourceOptions {
670
+ context: string;
671
+ namespace: string;
672
+ resourceRef: ResourceRef;
673
+ namespaced: boolean;
674
+ replicas: number;
675
+ }
676
+
677
+ export interface RolloutRestartOptions {
678
+ context: string;
679
+ namespace: string;
680
+ resourceRef: ResourceRef;
681
+ namespaced: boolean;
682
+ }
683
+
684
+ export interface DeleteResourceOptions {
685
+ context: string;
686
+ namespace: string;
687
+ resourceRef: ResourceRef;
688
+ namespaced: boolean;
689
+ }
690
+
691
+ export interface StartShellOptions {
692
+ context: string;
693
+ namespace: string;
694
+ resourceRef: ResourceRef;
695
+ namespaced: boolean;
696
+ }
697
+
698
+ export interface StartPortForwardOptions {
699
+ context: string;
700
+ namespace: string;
701
+ resourceRef: ResourceRef;
702
+ namespaced: boolean;
703
+ localPort: number;
704
+ remotePort: number;
705
+ }
706
+
707
+ export interface GetSecretValueOptions {
708
+ context: string;
709
+ namespace: string;
710
+ name: string;
711
+ key: string;
712
+ }
713
+
714
+ export interface GetAllSecretValuesOptions {
715
+ context: string;
716
+ namespace: string;
717
+ name: string;
718
+ }
719
+
720
+ interface KubeConfigContextResponse {
721
+ contexts?: Array<{ name?: string | undefined }> | undefined;
722
+ "current-context"?: string | undefined;
723
+ }
724
+
725
+ interface NamespaceListResponse {
726
+ items?: Array<{ metadata?: { name?: string | undefined } | undefined }> | undefined;
727
+ }
728
+
729
+ interface ApiResourcesLine {
730
+ name: string;
731
+ shortNames: string[];
732
+ group: string;
733
+ namespaced: boolean;
734
+ kind: string;
735
+ }
736
+
737
+ interface GenericListResponse {
738
+ apiVersion?: string | undefined;
739
+ kind?: string | undefined;
740
+ items?: GenericListItem[] | undefined;
741
+ }
742
+
743
+ interface GenericListItem {
744
+ kind?: string | undefined;
745
+ metadata?: {
746
+ name?: string | undefined;
747
+ namespace?: string | undefined;
748
+ creationTimestamp?: string | undefined;
749
+ } | undefined;
750
+ spec?: Record<string, unknown> | undefined;
751
+ status?: Record<string, unknown> | undefined;
752
+ // Event-specific top-level fields
753
+ type?: string | undefined;
754
+ reason?: string | undefined;
755
+ message?: string | undefined;
756
+ lastTimestamp?: string | undefined;
757
+ firstTimestamp?: string | undefined;
758
+ }
759
+
760
+ interface EventListResponse {
761
+ items?: EventResponseItem[] | undefined;
762
+ }
763
+
764
+ interface SecretResponse {
765
+ data?: Record<string, string> | undefined;
766
+ }
767
+
768
+ interface EventResponseItem {
769
+ metadata?: {
770
+ creationTimestamp?: string | undefined;
771
+ } | undefined;
772
+ involvedObject?: {
773
+ kind?: string | undefined;
774
+ name?: string | undefined;
775
+ namespace?: string | undefined;
776
+ } | undefined;
777
+ reason?: string | undefined;
778
+ message?: string | undefined;
779
+ type?: string | undefined;
780
+ source?: {
781
+ component?: string | undefined;
782
+ host?: string | undefined;
783
+ } | undefined;
784
+ reportingComponent?: string | undefined;
785
+ firstTimestamp?: string | undefined;
786
+ lastTimestamp?: string | undefined;
787
+ }
788
+
789
+ interface HelmListItem {
790
+ name: string;
791
+ namespace: string;
792
+ revision: string;
793
+ updated: string;
794
+ status: string;
795
+ chart: string;
796
+ app_version: string;
797
+ }
798
+
799
+ interface HelmStatusResponse {
800
+ name: string;
801
+ namespace: string;
802
+ version: number;
803
+ info: {
804
+ first_deployed: string;
805
+ last_deployed: string;
806
+ status: string;
807
+ notes?: string | undefined;
808
+ description?: string | undefined;
809
+ };
810
+ chart: {
811
+ metadata: {
812
+ name: string;
813
+ version: string;
814
+ appVersion?: string | undefined;
815
+ };
816
+ };
817
+ }
818
+
819
+ interface PodMetricsItem {
820
+ metadata?: { name?: string };
821
+ containers?: Array<{ usage?: { cpu?: string; memory?: string } }>;
822
+ }
823
+
824
+ interface NodeMetricsItem {
825
+ metadata?: { name?: string };
826
+ usage?: { cpu?: string; memory?: string };
827
+ }
828
+
829
+ function parseCpuNano(value: string): number {
830
+ if (!value) return 0;
831
+ if (value.endsWith("n")) return Number.parseInt(value, 10);
832
+ if (value.endsWith("m")) return Number.parseInt(value, 10) * 1_000_000;
833
+ // plain integer = cores
834
+ const n = Number.parseFloat(value);
835
+ return Number.isFinite(n) ? n * 1_000_000_000 : 0;
836
+ }
837
+
838
+ function parseMemBytes(value: string): number {
839
+ if (!value) return 0;
840
+ const units: Record<string, number> = {
841
+ Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4,
842
+ K: 1000, M: 1000 ** 2, G: 1000 ** 3, T: 1000 ** 4,
843
+ };
844
+ for (const [suffix, mult] of Object.entries(units)) {
845
+ if (value.endsWith(suffix)) {
846
+ return Number.parseFloat(value) * mult;
847
+ }
848
+ }
849
+ return Number.parseFloat(value) || 0;
850
+ }
851
+
852
+ function formatCpu(nano: number): string {
853
+ if (nano >= 1_000_000_000) return `${(nano / 1_000_000_000).toFixed(2)}`;
854
+ return `${Math.round(nano / 1_000_000)}m`;
855
+ }
856
+
857
+ function formatMem(bytes: number): string {
858
+ if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)}Gi`;
859
+ if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)}Mi`;
860
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)}Ki`;
861
+ return `${bytes}B`;
862
+ }
863
+
864
+ function parseKubectlError(stderr: string, args: string[]): Error {
865
+ const message = stderr.trim() || `kubectl ${args.join(" ")} failed`;
866
+ return new Error(message);
867
+ }
868
+
869
+ function resourceStatus(resource: GenericListItem): string {
870
+ const kind = (resource.kind ?? "").toLowerCase();
871
+ const status = resource.status ?? {};
872
+ const spec = resource.spec ?? {};
873
+
874
+ // Events
875
+ if (kind === "event" || kind === "events") {
876
+ return resource.type ?? "Normal";
877
+ }
878
+
879
+ // HorizontalPodAutoscalers
880
+ if (kind === "horizontalpodautoscaler") {
881
+ const current = typeof status.currentReplicas === "number" ? status.currentReplicas : "-";
882
+ const max = typeof spec.maxReplicas === "number" ? spec.maxReplicas : "-";
883
+ return `${current}/${max} replicas`;
884
+ }
885
+
886
+ // CronJobs
887
+ if (kind === "cronjob") {
888
+ const active = Array.isArray(status.active) ? status.active : [];
889
+ return active.length > 0 ? "active" : "idle";
890
+ }
891
+
892
+ // Nodes: summarize conditions
893
+ if (kind === "node") {
894
+ const conditions = Array.isArray(status.conditions) ? (status.conditions as Record<string, unknown>[]) : [];
895
+ const ready = conditions.find((c) => c.type === "Ready");
896
+ const memPressure = conditions.find((c) => c.type === "MemoryPressure" && c.status === "True");
897
+ const diskPressure = conditions.find((c) => c.type === "DiskPressure" && c.status === "True");
898
+
899
+ if (memPressure) return "MemoryPressure";
900
+ if (diskPressure) return "DiskPressure";
901
+ return ready?.status === "True" ? "Ready" : "NotReady";
902
+ }
903
+
904
+ // RBAC subjects count
905
+ if (kind === "rolebinding" || kind === "clusterrolebinding") {
906
+ const subjects = Array.isArray((resource as unknown as Record<string, unknown>).subjects)
907
+ ? ((resource as unknown as Record<string, unknown>).subjects as unknown[])
908
+ : [];
909
+ return `${subjects.length} subjects`;
910
+ }
911
+
912
+ if (typeof status.phase === "string") {
913
+ return status.phase;
914
+ }
915
+
916
+ if (typeof status.readyReplicas === "number" && typeof spec.replicas === "number") {
917
+ return `${status.readyReplicas}/${spec.replicas} ready`;
918
+ }
919
+
920
+ if (typeof status.availableReplicas === "number" && typeof spec.replicas === "number") {
921
+ return `${status.availableReplicas}/${spec.replicas} available`;
922
+ }
923
+
924
+ if (typeof status.capacity === "object") {
925
+ return "capacity";
926
+ }
927
+
928
+ return "-";
929
+ }
930
+
931
+ function resourceSummary(resource: GenericListItem): string {
932
+ const kind = (resource.kind ?? "").toLowerCase();
933
+ const spec = resource.spec ?? {};
934
+ const status = resource.status ?? {};
935
+
936
+ // Events
937
+ if (kind === "event" || kind === "events") {
938
+ const reason = resource.reason ?? "";
939
+ const message = (resource.message ?? "").slice(0, 60);
940
+ return reason ? `${reason}: ${message}` : message;
941
+ }
942
+
943
+ // HorizontalPodAutoscalers
944
+ if (kind === "horizontalpodautoscaler") {
945
+ const min = spec.minReplicas ?? "-";
946
+ const max = spec.maxReplicas ?? "-";
947
+ const ref = (spec.scaleTargetRef as Record<string, unknown> | undefined)?.name ?? "-";
948
+ return `min:${min} max:${max} ref:${ref}`;
949
+ }
950
+
951
+ // CronJobs
952
+ if (kind === "cronjob") {
953
+ const schedule = typeof spec.schedule === "string" ? spec.schedule : "-";
954
+ const lastRun = typeof status.lastScheduleTime === "string" ? formatAge(status.lastScheduleTime) : "-";
955
+ return `${schedule} last: ${lastRun}`;
956
+ }
957
+
958
+ // Nodes
959
+ if (kind === "node") {
960
+ const nodeInfo = (status.nodeInfo ?? {}) as Record<string, unknown>;
961
+ const kubeletVersion = typeof nodeInfo.kubeletVersion === "string" ? nodeInfo.kubeletVersion : "";
962
+ const labels = (resource as unknown as { metadata?: { labels?: Record<string, string> } }).metadata?.labels ?? {};
963
+ const roles = Object.keys(labels)
964
+ .filter((k) => k.startsWith("node-role.kubernetes.io/"))
965
+ .map((k) => k.replace("node-role.kubernetes.io/", ""))
966
+ .join(",") || "worker";
967
+ return kubeletVersion ? `${kubeletVersion} roles: ${roles}` : `roles: ${roles}`;
968
+ }
969
+
970
+ // Roles/ClusterRoles: show rule count
971
+ if (kind === "role" || kind === "clusterrole") {
972
+ const rules = (resource as unknown as { rules?: unknown[] }).rules ?? [];
973
+ return `${rules.length} rules`;
974
+ }
975
+
976
+ // RoleBindings/ClusterRoleBindings: show roleRef
977
+ if (kind === "rolebinding" || kind === "clusterrolebinding") {
978
+ const roleRef = (resource as unknown as { roleRef?: Record<string, unknown> }).roleRef;
979
+ if (roleRef) {
980
+ return `\u2192 ${roleRef.kind ?? ""}/${roleRef.name ?? ""}`;
981
+ }
982
+ return "";
983
+ }
984
+
985
+ if (typeof spec.clusterIP === "string") {
986
+ return `clusterIP ${spec.clusterIP}`;
987
+ }
988
+
989
+ if (typeof spec.nodeName === "string") {
990
+ return `node ${spec.nodeName}`;
991
+ }
992
+
993
+ if (typeof status.hostIP === "string") {
994
+ return `host ${status.hostIP}`;
995
+ }
996
+
997
+ if (typeof status.podIP === "string") {
998
+ return `podIP ${status.podIP}`;
999
+ }
1000
+
1001
+ return "";
1002
+ }
1003
+
1004
+ function curatedKinds(kinds: ResourceKind[]): ResourceKind[] {
1005
+ const preferredOrder = [
1006
+ "pods",
1007
+ "deployments",
1008
+ "statefulsets",
1009
+ "daemonsets",
1010
+ "services",
1011
+ "ingresses",
1012
+ "jobs",
1013
+ "cronjobs",
1014
+ "horizontalpodautoscalers",
1015
+ "configmaps",
1016
+ "secrets",
1017
+ "nodes",
1018
+ "namespaces",
1019
+ "events",
1020
+ "persistentvolumeclaims",
1021
+ "persistentvolumes",
1022
+ "serviceaccounts",
1023
+ "roles",
1024
+ "clusterroles",
1025
+ "rolebindings",
1026
+ "clusterrolebindings",
1027
+ "networkpolicies",
1028
+ "poddisruptionbudgets",
1029
+ ];
1030
+
1031
+ const kindMap = new Map(kinds.map((kind) => [kind.name, kind]));
1032
+ const curated = preferredOrder
1033
+ .map((name) => kindMap.get(name))
1034
+ .filter((kind): kind is ResourceKind => kind !== undefined);
1035
+
1036
+ if (curated.length > 0) {
1037
+ return curated;
1038
+ }
1039
+
1040
+ return kinds.slice(0, 20);
1041
+ }
1042
+
1043
+ function normalizeKind(kind: string): string {
1044
+ return kind.trim().toLowerCase();
1045
+ }
1046
+
1047
+ function normalizeLogTarget(ref: ResourceRef): { target: string; includeAllPods: boolean } | undefined {
1048
+ const kind = normalizeKind(ref.kind);
1049
+
1050
+ if (kind === "pod" || kind === "pods") {
1051
+ return { target: `pod/${ref.name}`, includeAllPods: false };
1052
+ }
1053
+
1054
+ if (kind === "deployment" || kind === "deployments") {
1055
+ return { target: `deployment/${ref.name}`, includeAllPods: true };
1056
+ }
1057
+
1058
+ if (kind === "job" || kind === "jobs") {
1059
+ return { target: `job/${ref.name}`, includeAllPods: true };
1060
+ }
1061
+
1062
+ if (kind === "replicaset" || kind === "replicasets") {
1063
+ return { target: `replicaset/${ref.name}`, includeAllPods: true };
1064
+ }
1065
+
1066
+ if (kind === "statefulset" || kind === "statefulsets") {
1067
+ return { target: `statefulset/${ref.name}`, includeAllPods: true };
1068
+ }
1069
+
1070
+ if (kind === "daemonset" || kind === "daemonsets") {
1071
+ return { target: `daemonset/${ref.name}`, includeAllPods: true };
1072
+ }
1073
+
1074
+ return undefined;
1075
+ }
1076
+
1077
+ function normalizeResourceTarget(ref: ResourceRef): string {
1078
+ const kind = normalizeKind(ref.kind);
1079
+ return `${kind}/${ref.name}`;
1080
+ }
1081
+
1082
+ function normalizePortForwardTarget(ref: ResourceRef): string | undefined {
1083
+ const kind = normalizeKind(ref.kind);
1084
+
1085
+ if (kind === "pod" || kind === "pods") {
1086
+ return `pod/${ref.name}`;
1087
+ }
1088
+
1089
+ if (kind === "service" || kind === "services") {
1090
+ return `service/${ref.name}`;
1091
+ }
1092
+
1093
+ if (kind === "deployment" || kind === "deployments") {
1094
+ return `deployment/${ref.name}`;
1095
+ }
1096
+
1097
+ if (kind === "replicaset" || kind === "replicasets") {
1098
+ return `replicaset/${ref.name}`;
1099
+ }
1100
+
1101
+ if (kind === "statefulset" || kind === "statefulsets") {
1102
+ return `statefulset/${ref.name}`;
1103
+ }
1104
+
1105
+ return undefined;
1106
+ }
1107
+
1108
+ function supportsRolloutRestart(ref: ResourceRef): boolean {
1109
+ const kind = normalizeKind(ref.kind);
1110
+ return ["deployment", "deployments", "statefulset", "statefulsets", "daemonset", "daemonsets"].includes(kind);
1111
+ }
1112
+
1113
+ function supportsScale(ref: ResourceRef): boolean {
1114
+ const kind = normalizeKind(ref.kind);
1115
+ return ["deployment", "deployments", "statefulset", "statefulsets", "replicaset", "replicasets"].includes(kind);
1116
+ }