openk8s 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/package.json +11 -10
  3. package/src/app/__tests__/app-state.test.ts +47 -5
  4. package/src/app/__tests__/utils.test.ts +5 -14
  5. package/src/app/app-actions.ts +5 -0
  6. package/src/app/app-state.ts +11 -2
  7. package/src/app/app.tsx +57 -46
  8. package/src/app/components/detail-sections.tsx +2 -2
  9. package/src/app/components/footer.tsx +23 -30
  10. package/src/app/components/inspector.tsx +28 -43
  11. package/src/app/components/kind-rows.tsx +1 -1
  12. package/src/app/components/notification-card.tsx +145 -0
  13. package/src/app/components/notification-tray.tsx +19 -38
  14. package/src/app/components/overlays/delete-confirm-overlay.tsx +15 -17
  15. package/src/app/components/overlays/helm-rollback-overlay.tsx +12 -66
  16. package/src/app/components/overlays/help-overlay.tsx +173 -0
  17. package/src/app/components/overlays/index.ts +5 -2
  18. package/src/app/components/overlays/logs-dialog.tsx +50 -80
  19. package/src/app/components/overlays/notification-history-overlay.tsx +79 -0
  20. package/src/app/components/overlays/port-forward-list-overlay.tsx +85 -0
  21. package/src/app/components/overlays/port-forward-overlay.tsx +16 -56
  22. package/src/app/components/overlays/scale-dialog.tsx +12 -67
  23. package/src/app/components/overlays/select-overlay.tsx +5 -14
  24. package/src/app/components/overlays/shared.tsx +85 -6
  25. package/src/app/components/resource-rows.tsx +1 -1
  26. package/src/app/constants.ts +24 -0
  27. package/src/app/hooks/keyboard/filter-handlers.ts +2 -1
  28. package/src/app/hooks/keyboard/global-handlers.ts +32 -11
  29. package/src/app/hooks/keyboard/helm-handlers.ts +14 -9
  30. package/src/app/hooks/keyboard/keys.ts +18 -0
  31. package/src/app/hooks/keyboard/logs-handlers.ts +3 -1
  32. package/src/app/hooks/keyboard/navigation-handlers.ts +5 -4
  33. package/src/app/hooks/keyboard/overlay-handlers.ts +11 -7
  34. package/src/app/hooks/keyboard/port-forward-handlers.ts +19 -14
  35. package/src/app/hooks/keyboard/shell-edit-handlers.ts +45 -17
  36. package/src/app/hooks/use-app-keyboard.ts +22 -2
  37. package/src/app/hooks/use-app-side-effects.ts +10 -7
  38. package/src/app/hooks/use-clipboard.ts +8 -10
  39. package/src/app/hooks/use-data-fetching.ts +11 -5
  40. package/src/app/hooks/use-log-stream.ts +8 -4
  41. package/src/app/hooks/use-notifications.ts +92 -0
  42. package/src/app/hooks/use-port-forward.ts +19 -4
  43. package/src/app/persistence.ts +7 -3
  44. package/src/app/syntax-theme.ts +31 -0
  45. package/src/app/theme.ts +2 -3
  46. package/src/app/use-footer-hints.ts +21 -16
  47. package/src/app/utils.ts +1 -9
  48. package/src/assets/tree-sitter/yaml/highlights.scm +79 -0
  49. package/src/assets/tree-sitter/yaml/tree-sitter-yaml.wasm +0 -0
  50. package/src/index.tsx +22 -2
  51. package/src/lib/k8s/__tests__/k8s-format.test.ts +17 -0
  52. package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
  53. package/src/lib/k8s/detail-builders/event-builder.ts +17 -11
  54. package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +34 -31
  55. package/src/lib/k8s/detail-builders/node-builder.ts +22 -11
  56. package/src/lib/k8s/detail-builders/overview-builder.ts +4 -17
  57. package/src/lib/k8s/detail-builders/pod-builder.ts +52 -24
  58. package/src/lib/k8s/detail-builders/rbac-builder.ts +20 -29
  59. package/src/lib/k8s/k8s-format.ts +14 -9
  60. package/src/lib/k8s/resource-detail-builder.ts +1 -1
  61. package/src/lib/k8s/resource-parser.ts +17 -2
  62. package/src/lib/k8s/types.ts +10 -1
  63. package/src/lib/kubectl/__tests__/metrics-utils.test.ts +9 -0
  64. package/src/lib/kubectl/kubectl-helpers.ts +16 -11
  65. package/src/lib/kubectl/kubectl-service.ts +39 -39
  66. package/src/lib/kubectl/kubectl-types.ts +10 -1
  67. package/src/lib/kubectl/metrics-utils.ts +21 -7
  68. package/src/lib/kubectl/spawn-utils.ts +50 -11
  69. package/src/app/__tests__/components/inspector-tokens.test.ts +0 -101
  70. package/src/app/components/inspector-tokens.ts +0 -93
  71. package/src/app/components/port-forwards-tray.tsx +0 -57
@@ -1,14 +1,31 @@
1
1
  import { spawn, type ChildProcess, type StdioOptions } from "node:child_process";
2
+ import { parseKubectlError } from "./kubectl-helpers";
2
3
 
3
4
  const DEFAULT_TIMEOUT_MS = 15_000;
4
5
  let shutdownController: AbortController | undefined;
5
6
 
7
+ const persistentProcesses = new Set<ChildProcess>();
8
+
6
9
  export function setAbortController(controller: AbortController): void {
7
10
  shutdownController = controller;
8
11
  }
9
12
 
10
13
  export function abortRunningProcesses(): void {
11
14
  shutdownController?.abort();
15
+ for (const child of persistentProcesses) {
16
+ safeKill(child, "SIGTERM");
17
+ }
18
+ persistentProcesses.clear();
19
+ }
20
+
21
+ export function safeKill(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): void {
22
+ try {
23
+ if (child.exitCode === null && !child.killed) {
24
+ child.kill(signal);
25
+ }
26
+ } catch {
27
+ // ESRCH: process already exited. Safe to ignore.
28
+ }
12
29
  }
13
30
 
14
31
  export interface RunCommandOptions {
@@ -28,23 +45,30 @@ export function runCommand(options: RunCommandOptions): Promise<CommandResult> {
28
45
  const child = spawn(options.command, options.args, { env: process.env, stdio: ["pipe", "pipe", "pipe"] });
29
46
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
30
47
 
31
- const timeoutId = setTimeout(() => {
48
+ const onShutdown = (): void => {
32
49
  if (settled) {
33
50
  return;
34
51
  }
35
52
  settled = true;
36
- child.kill("SIGTERM");
37
- reject(new Error(`${options.command} ${options.args.slice(0, 4).join(" ")} timed out after ${timeoutMs / 1_000}s`));
38
- }, timeoutMs);
53
+ clearTimeout(timeoutId);
54
+ safeKill(child, "SIGTERM");
55
+ reject(new Error("Application shutting down"));
56
+ };
39
57
 
40
- const onShutdown = (): void => {
58
+ const timeoutId = setTimeout(() => {
41
59
  if (settled) {
42
60
  return;
43
61
  }
44
62
  settled = true;
45
- clearTimeout(timeoutId);
46
- child.kill("SIGTERM");
47
- reject(new Error("Application shutting down"));
63
+ removeAbortListener();
64
+ safeKill(child, "SIGTERM");
65
+ reject(new Error(`${options.command} ${options.args.slice(0, 4).join(" ")} timed out after ${timeoutMs / 1_000}s`));
66
+ }, timeoutMs);
67
+
68
+ const removeAbortListener = (): void => {
69
+ if (shutdownController) {
70
+ shutdownController.signal.removeEventListener("abort", onShutdown);
71
+ }
48
72
  };
49
73
 
50
74
  if (shutdownController) {
@@ -68,12 +92,15 @@ export function runCommand(options: RunCommandOptions): Promise<CommandResult> {
68
92
 
69
93
  child.on("error", (error) => {
70
94
  clearTimeout(timeoutId);
95
+ removeAbortListener();
96
+ if (settled) return;
71
97
  settled = true;
72
98
  reject(error);
73
99
  });
74
100
 
75
101
  child.on("close", (code) => {
76
102
  clearTimeout(timeoutId);
103
+ removeAbortListener();
77
104
 
78
105
  if (settled) {
79
106
  return;
@@ -86,8 +113,7 @@ export function runCommand(options: RunCommandOptions): Promise<CommandResult> {
86
113
  }
87
114
 
88
115
  settled = true;
89
- const message = stderr.trim() || `${options.command} ${options.args.join(" ")} failed`;
90
- reject(new Error(message));
116
+ reject(parseKubectlError(stderr, options.args, options.command));
91
117
  });
92
118
  });
93
119
  }
@@ -101,8 +127,21 @@ export function runJson<T>(options: RunCommandOptions): Promise<T> {
101
127
  }
102
128
 
103
129
  export function startPersistentProcess(options: { command: string; args: string[]; stdio?: StdioOptions }): ChildProcess {
104
- return spawn(options.command, options.args, {
130
+ const child = spawn(options.command, options.args, {
105
131
  env: process.env,
106
132
  stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
107
133
  });
134
+ persistentProcesses.add(child);
135
+ child.on("close", () => {
136
+ persistentProcesses.delete(child);
137
+ });
138
+ child.on("error", () => {
139
+ persistentProcesses.delete(child);
140
+ });
141
+ return child;
142
+ }
143
+
144
+ export function stopPersistentProcess(child: ChildProcess): void {
145
+ persistentProcesses.delete(child);
146
+ safeKill(child, "SIGINT");
108
147
  }
@@ -1,101 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { tokenizeYamlLine, tokenizeDescribeLine } from "../../components/inspector-tokens";
3
- import { YAML_COMMENT, YAML_KEY, TEXT_SUBTLE, YAML_VALUE } from "../../theme";
4
-
5
- describe("tokenizeYamlLine", () => {
6
- test("empty line", () => {
7
- const tokens = tokenizeYamlLine("");
8
- expect(tokens[0]?.text).toBe(" ");
9
- });
10
-
11
- test("whitespace line", () => {
12
- const tokens = tokenizeYamlLine(" ");
13
- expect(tokens[0]?.text).toBe(" ");
14
- });
15
-
16
- test("comment line", () => {
17
- const tokens = tokenizeYamlLine("# this is a comment");
18
- expect(tokens).toHaveLength(1);
19
- expect(tokens[0]?.fg).toBe(YAML_COMMENT);
20
- });
21
-
22
- test("key: value line", () => {
23
- const tokens = tokenizeYamlLine("name: nginx");
24
- expect(tokens).toHaveLength(4);
25
- expect(tokens[0]?.text).toBe("");
26
- expect(tokens[1]?.text).toBe("name");
27
- expect(tokens[1]?.fg).toBe(YAML_KEY);
28
- expect(tokens[3]?.text).toBe("nginx");
29
- expect(tokens[3]?.fg).toBe(YAML_VALUE);
30
- });
31
-
32
- test("indented key: value", () => {
33
- const tokens = tokenizeYamlLine(" name: nginx");
34
- expect(tokens[0]?.text).toBe(" ");
35
- expect(tokens[1]?.text).toBe("name");
36
- expect(tokens[1]?.fg).toBe(YAML_KEY);
37
- });
38
-
39
- test("list item with value", () => {
40
- const tokens = tokenizeYamlLine("- name: nginx");
41
- expect(tokens[0]?.text).toBe("- ");
42
- expect(tokens[1]?.text).toBe("name");
43
- expect(tokens[1]?.fg).toBe(YAML_KEY);
44
- });
45
-
46
- test("bare list marker", () => {
47
- const tokens = tokenizeYamlLine("-");
48
- expect(tokens).toHaveLength(1);
49
- expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
50
- });
51
-
52
- test("key only (ends with colon)", () => {
53
- const tokens = tokenizeYamlLine("metadata:");
54
- expect(tokens).toHaveLength(3);
55
- expect(tokens[1]?.text).toBe("metadata");
56
- expect(tokens[1]?.fg).toBe(YAML_KEY);
57
- expect(tokens[2]?.text).toBe(":");
58
- });
59
-
60
- test("plain text fallthrough", () => {
61
- const tokens = tokenizeYamlLine("some random text");
62
- expect(tokens).toHaveLength(1);
63
- expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
64
- });
65
- });
66
-
67
- describe("tokenizeDescribeLine", () => {
68
- test("empty line", () => {
69
- const tokens = tokenizeDescribeLine("");
70
- expect(tokens[0]?.text).toBe(" ");
71
- });
72
-
73
- test("section header", () => {
74
- const tokens = tokenizeDescribeLine("Name: test-pod");
75
- expect(tokens).toHaveLength(3);
76
- expect(tokens[0]?.text).toBe("Name");
77
- expect(tokens[0]?.fg).toBe(YAML_KEY);
78
- expect(tokens[2]?.fg).toBe(YAML_VALUE);
79
- });
80
-
81
- test("indented key: value (double space after colon)", () => {
82
- const tokens = tokenizeDescribeLine(" Namespace: default");
83
- expect(tokens).toHaveLength(2);
84
- expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
85
- expect(tokens[1]?.fg).toBe(YAML_VALUE);
86
- });
87
-
88
- test("key: single value", () => {
89
- const tokens = tokenizeDescribeLine("key:value");
90
- expect(tokens).toHaveLength(3);
91
- expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
92
- expect(tokens[1]?.text).toBe(":");
93
- expect(tokens[2]?.text).toBe("value");
94
- });
95
-
96
- test("plain text", () => {
97
- const tokens = tokenizeDescribeLine("Some plain description text");
98
- expect(tokens).toHaveLength(1);
99
- expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
100
- });
101
- });
@@ -1,93 +0,0 @@
1
- import { YAML_COMMENT, YAML_KEY, TEXT_SUBTLE, YAML_VALUE } from "../theme";
2
-
3
- export interface YamlToken {
4
- text: string;
5
- fg: string;
6
- }
7
-
8
- export function tokenizeDescribeLine(line: string): YamlToken[] {
9
- if (!line.trim()) {
10
- return [{ text: line || " ", fg: TEXT_SUBTLE }];
11
- }
12
-
13
- if (/^[A-Z][A-Za-z ]+:/.test(line.trimStart()) && !line.startsWith(" ")) {
14
- const colonIdx = line.indexOf(":");
15
- const key = line.slice(0, colonIdx);
16
- const rest = line.slice(colonIdx + 1);
17
- return [
18
- { text: key, fg: YAML_KEY },
19
- { text: ":", fg: TEXT_SUBTLE },
20
- { text: rest, fg: YAML_VALUE },
21
- ];
22
- }
23
-
24
- const colonSpaceIdx = line.indexOf(": ");
25
- if (colonSpaceIdx > 0) {
26
- const key = line.slice(0, colonSpaceIdx);
27
- const value = line.slice(colonSpaceIdx + 1);
28
- return [
29
- { text: key, fg: TEXT_SUBTLE },
30
- { text: value, fg: YAML_VALUE },
31
- ];
32
- }
33
-
34
- const colonIdx = line.indexOf(":");
35
- if (colonIdx > 0 && colonIdx < line.length - 1) {
36
- const key = line.slice(0, colonIdx);
37
- const value = line.slice(colonIdx + 1);
38
- return [
39
- { text: key, fg: TEXT_SUBTLE },
40
- { text: ":", fg: TEXT_SUBTLE },
41
- { text: value, fg: YAML_VALUE },
42
- ];
43
- }
44
-
45
- return [{ text: line, fg: TEXT_SUBTLE }];
46
- }
47
-
48
- export function tokenizeYamlLine(line: string): YamlToken[] {
49
- if (!line.trim()) {
50
- return [{ text: line || " ", fg: TEXT_SUBTLE }];
51
- }
52
-
53
- if (/^\s*#/.test(line)) {
54
- return [{ text: line, fg: YAML_COMMENT }];
55
- }
56
-
57
- const indentLen = line.length - line.trimStart().length;
58
- const indent = line.slice(0, indentLen);
59
- let rest = line.trimStart();
60
-
61
- let listMarker = "";
62
- if (rest.startsWith("- ")) {
63
- listMarker = "- ";
64
- rest = rest.slice(2);
65
- } else if (rest === "-") {
66
- return [{ text: line, fg: TEXT_SUBTLE }];
67
- }
68
-
69
- const colonSpaceIdx = rest.indexOf(": ");
70
- const isKeyOnly = rest === rest.trimEnd() && rest.endsWith(":");
71
-
72
- if (colonSpaceIdx !== -1) {
73
- const key = rest.slice(0, colonSpaceIdx);
74
- const value = rest.slice(colonSpaceIdx + 2);
75
- return [
76
- { text: indent + listMarker, fg: TEXT_SUBTLE },
77
- { text: key, fg: YAML_KEY },
78
- { text: ": ", fg: TEXT_SUBTLE },
79
- { text: value, fg: YAML_VALUE },
80
- ];
81
- }
82
-
83
- if (isKeyOnly) {
84
- const key = rest.slice(0, -1);
85
- return [
86
- { text: indent + listMarker, fg: TEXT_SUBTLE },
87
- { text: key, fg: YAML_KEY },
88
- { text: ":", fg: TEXT_SUBTLE },
89
- ];
90
- }
91
-
92
- return [{ text: line, fg: TEXT_SUBTLE }];
93
- }
@@ -1,57 +0,0 @@
1
- import { TextAttributes } from "@opentui/core";
2
-
3
- import { DANGER, GLYPHS, KEY_HINT, OVERLAY_SURFACE, PANEL_BORDER, TEXT_PRIMARY, TEXT_SUBTLE } from "../theme";
4
- import { activePortForwardLabel } from "../utils";
5
- import type { ActivePortForward } from "../../lib/k8s/types";
6
-
7
- export interface PortForwardsTrayProps {
8
- forwards: ActivePortForward[];
9
- onStop: (id: string) => void;
10
- }
11
-
12
- export function PortForwardsTray({ forwards, onStop }: PortForwardsTrayProps) {
13
- if (forwards.length === 0) {
14
- return undefined;
15
- }
16
-
17
- return (
18
- <box
19
- style={{
20
- border: true,
21
- borderColor: PANEL_BORDER,
22
- borderStyle: "rounded",
23
- backgroundColor: OVERLAY_SURFACE,
24
- paddingLeft: 1,
25
- paddingRight: 1,
26
- flexDirection: "column",
27
- }}
28
- >
29
- {/* Tray heading */}
30
- <text fg={KEY_HINT}>
31
- {GLYPHS.forward}
32
- <span fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>{" Port Forwards"}</span>
33
- </text>
34
- <box style={{ flexDirection: "column", width: "100%" }}>
35
- {forwards.map((forward) => {
36
- const isReady = forward.status === "ready";
37
- const statusDot = isReady ? GLYPHS.dot : GLYPHS.dotEmpty;
38
- const statusFg = isReady ? KEY_HINT : TEXT_SUBTLE;
39
-
40
- return (
41
- <box key={forward.id} style={{ flexDirection: "row", justifyContent: "space-between", width: "100%" }}>
42
- <text fg={isReady ? TEXT_PRIMARY : TEXT_SUBTLE}>
43
- <span fg={statusFg}>{statusDot}</span>
44
- {` ${activePortForwardLabel(forward)} `}
45
- <span fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>{forward.status}</span>
46
- </text>
47
- <text fg={DANGER} onMouseDown={() => onStop(forward.id)}>
48
- <span fg={DANGER}>{GLYPHS.stop}</span>
49
- {" stop"}
50
- </text>
51
- </box>
52
- );
53
- })}
54
- </box>
55
- </box>
56
- );
57
- }