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,6 +3,7 @@ import type { AppState, ActivePortForward, ResourceRef, ResourceKind, ResourceDe
3
3
  import type { StartPortForwardLifecycleOptions } from "../../utils";
4
4
  import { parseLocalPort } from "../../utils";
5
5
  import { buildPortForwardEntries } from "../../components/overlays";
6
+ import { isDown, isUp } from "./keys";
6
7
 
7
8
  export function handlePortForwardKeys(
8
9
  key: { name: string },
@@ -28,23 +29,27 @@ export function handlePortForwardKeys(
28
29
 
29
30
  const pfEntries = buildPortForwardEntries(activeDetail?.portForwards ?? [], selectedResourcePortForwards);
30
31
 
31
- if (key.name === "up" || key.name === "k") {
32
- const newIndex = Math.max(0, overlayIndex - 1);
33
- setOverlayIndex(newIndex);
34
- const entry = pfEntries[newIndex];
35
- if (entry && !entry.activeForward) {
36
- setPortForwardLocalPort(String(entry.suggestedLocalPort));
37
- }
32
+ if (isUp(key)) {
33
+ setOverlayIndex((current) => {
34
+ const newIndex = Math.max(0, current - 1);
35
+ const entry = pfEntries[newIndex];
36
+ if (entry && !entry.activeForward) {
37
+ setPortForwardLocalPort(String(entry.suggestedLocalPort));
38
+ }
39
+ return newIndex;
40
+ });
38
41
  return true;
39
42
  }
40
43
 
41
- if (key.name === "down" || key.name === "j") {
42
- const newIndex = Math.min(Math.max(0, pfEntries.length - 1), overlayIndex + 1);
43
- setOverlayIndex(newIndex);
44
- const entry = pfEntries[newIndex];
45
- if (entry && !entry.activeForward) {
46
- setPortForwardLocalPort(String(entry.suggestedLocalPort));
47
- }
44
+ if (isDown(key)) {
45
+ setOverlayIndex((current) => {
46
+ const newIndex = Math.min(Math.max(0, pfEntries.length - 1), current + 1);
47
+ const entry = pfEntries[newIndex];
48
+ if (entry && !entry.activeForward) {
49
+ setPortForwardLocalPort(String(entry.suggestedLocalPort));
50
+ }
51
+ return newIndex;
52
+ });
48
53
  return true;
49
54
  }
50
55
 
@@ -3,8 +3,10 @@ import { spawn } from "node:child_process";
3
3
 
4
4
  import type { AppAction } from "../../app-actions";
5
5
  import type { AppState, ResourceRef, ResourceKind, ResourceDetail, NotificationTone } from "../../../lib/k8s/types";
6
+ import { isHelmRelease } from "../../../lib/kubectl/kubectl-helpers";
6
7
  import { statusLine } from "../../utils";
7
8
  import { KubectlService } from "../../../lib/kubectl/kubectl-service";
9
+ import type { ToastOptions } from "../use-notifications";
8
10
 
9
11
  export function handleShellKeys(
10
12
  key: { name: string; shift?: boolean },
@@ -14,13 +16,13 @@ export function handleShellKeys(
14
16
  activeResourceRef: ResourceRef | undefined,
15
17
  selectedKind: ResourceKind | undefined,
16
18
  kubectl: KubectlService,
17
- toastError: (error: unknown) => void,
19
+ toastError: (error: unknown, options?: ToastOptions) => void,
18
20
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
19
21
  ): boolean {
20
22
  if (state.overlay) return false;
21
23
 
22
24
  if (key.name === "x" && activeResourceRef && selectedKind && state.activeContext) {
23
- if (activeResourceRef.kind.toLowerCase() === "helmreleases") {
25
+ if (isHelmRelease(activeResourceRef)) {
24
26
  dispatch({ type: "setStatusMessage", message: "Shell not supported for Helm releases" });
25
27
  return true;
26
28
  }
@@ -73,8 +75,8 @@ export function handleEditKeys(
73
75
  selectedKind: ResourceKind | undefined,
74
76
  activeDetail: ResourceDetail | undefined,
75
77
  kubectl: KubectlService,
76
- toast: (tone: NotificationTone, message: string) => void,
77
- toastError: (error: unknown) => void,
78
+ toast: (tone: NotificationTone, message: string, options?: ToastOptions) => void,
79
+ toastError: (error: unknown, options?: ToastOptions) => void,
78
80
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
79
81
  ): boolean {
80
82
  if (state.overlay) return false;
@@ -84,11 +86,11 @@ export function handleEditKeys(
84
86
  const namespace = state.activeNamespace;
85
87
  const namespaced = selectedKind.namespaced;
86
88
  const target = activeResourceRef;
87
- const isHelm = target.kind.toLowerCase() === "helmreleases";
89
+ const isHelm = isHelmRelease(target);
88
90
 
89
91
  if (isHelm) {
90
92
  const chartName = activeDetail?.helmChart ?? "";
91
- const values = activeDetail?.yaml ?? "";
93
+ const values = activeDetail?.helmValues ?? "";
92
94
  const tmpPath = `/tmp/openk8s-${target.name}-${Date.now()}.yaml`;
93
95
  const cleanupTmp = (): void => { try { unlinkSync(tmpPath); } catch { /* ignore */ } };
94
96
 
@@ -111,7 +113,10 @@ export function handleEditKeys(
111
113
  const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
112
114
  const editorChild = spawn(editor, [tmpPath], { env: process.env, stdio: "inherit" });
113
115
 
114
- editorChild.on("close", (code) => {
116
+ let editHandled = false;
117
+ const finishEdit = (code: number | null): void => {
118
+ if (editHandled) return;
119
+ editHandled = true;
115
120
  if (code === 0) {
116
121
  kubectl
117
122
  .helmUpgradeValues({ context, namespace, name: target.name, chart: chartName, valuesFile: tmpPath })
@@ -130,9 +135,15 @@ export function handleEditKeys(
130
135
  dispatch({ type: "setStatusMessage", message: "Edit cancelled" });
131
136
  cleanupTmp();
132
137
  }
138
+ };
139
+
140
+ editorChild.on("close", (code) => {
141
+ finishEdit(code);
133
142
  });
134
143
 
135
144
  editorChild.on("error", (err) => {
145
+ if (editHandled) return;
146
+ editHandled = true;
136
147
  renderer.resume();
137
148
  toastError(err);
138
149
  cleanupTmp();
@@ -144,7 +155,10 @@ export function handleEditKeys(
144
155
 
145
156
  const editChild = kubectl.editResource({ context, namespace, resourceRef: target, namespaced });
146
157
 
158
+ let editClosed = false;
147
159
  editChild.on("close", (code) => {
160
+ if (editClosed) return;
161
+ editClosed = true;
148
162
  renderer.resume();
149
163
  if (code === 0) {
150
164
  dispatch({ type: "setStatusMessage", message: `Saved ${statusLine({ ref: target })}` });
@@ -155,6 +169,8 @@ export function handleEditKeys(
155
169
  });
156
170
 
157
171
  editChild.on("error", (err) => {
172
+ if (editClosed) return;
173
+ editClosed = true;
158
174
  renderer.resume();
159
175
  toastError(err);
160
176
  dispatch({ type: "setStatusMessage", message: `Edit failed for ${statusLine({ ref: target })}` });
@@ -163,11 +179,6 @@ export function handleEditKeys(
163
179
  return true;
164
180
  }
165
181
 
166
- // Shift+S → scale dialog
167
- if (key.shift && key.name === "s" && activeResourceRef && selectedKind && state.activeContext && kubectl.canScale(activeResourceRef)) {
168
- return false; // handled by scale handler
169
- }
170
-
171
182
  return false;
172
183
  }
173
184
 
@@ -179,11 +190,16 @@ export function handleScaleKeys(
179
190
  selectedKind: ResourceKind | undefined,
180
191
  scaleReplicasInput: string,
181
192
  kubectl: KubectlService,
182
- toastError: (error: unknown) => void,
193
+ toastError: (error: unknown, options?: ToastOptions) => void,
183
194
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
184
195
  ): boolean {
185
196
  if (state.overlay !== "scale") return false;
186
197
 
198
+ if (key.name === "escape") {
199
+ dispatch({ type: "setOverlay", overlay: undefined });
200
+ return true;
201
+ }
202
+
187
203
  if (key.name === "return" && activeResourceRef && selectedKind && state.activeContext) {
188
204
  const replicas = Number.parseInt(scaleReplicasInput, 10);
189
205
 
@@ -207,7 +223,7 @@ export function handleScaleKeys(
207
223
  setManualRefreshNonce((current) => current + 1);
208
224
  })
209
225
  .catch((error: unknown) => {
210
- toastError(error);
226
+ toastError(error, { actions: [{ label: "retry", onAction: () => { void kubectl.scaleResource({ context, namespace, resourceRef: target, namespaced, replicas }).then(() => setManualRefreshNonce((c) => c + 1)).catch(toastError); } }] });
211
227
  dispatch({
212
228
  type: "setStatusMessage",
213
229
  message: `Scale failed for ${statusLine({ ref: target })}`,
@@ -227,7 +243,7 @@ export function handleRolloutRestartKeys(
227
243
  activeResourceRef: ResourceRef | undefined,
228
244
  selectedKind: ResourceKind | undefined,
229
245
  kubectl: KubectlService,
230
- toastError: (error: unknown) => void,
246
+ toastError: (error: unknown, options?: ToastOptions) => void,
231
247
  setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
232
248
  ): boolean {
233
249
  if (state.overlay) return false;
@@ -249,7 +265,7 @@ export function handleRolloutRestartKeys(
249
265
  setManualRefreshNonce((current) => current + 1);
250
266
  })
251
267
  .catch((error: unknown) => {
252
- toastError(error);
268
+ toastError(error, { actions: [{ label: "retry", onAction: () => { void kubectl.rolloutRestart({ context, namespace, resourceRef: target, namespaced }).then(() => setManualRefreshNonce((c) => c + 1)).catch(toastError); } }] });
253
269
  dispatch({
254
270
  type: "setStatusMessage",
255
271
  message: `Rollout restart failed for ${statusLine({ ref: target })}`,
@@ -7,6 +7,7 @@ import type { AppState } from "../../lib/k8s/types";
7
7
  import type { ActivePortForward, NotificationTone, ResourceDetail, ResourceKind, ResourceListItem, ResourceRef } from "../../lib/k8s/types";
8
8
  import type { LogsDialogActions } from "../components/overlays";
9
9
  import type { StartPortForwardLifecycleOptions } from "../utils";
10
+ import type { ToastOptions } from "./use-notifications";
10
11
  import { KubectlService } from "../../lib/kubectl/kubectl-service";
11
12
 
12
13
  import { handleCtrlC } from "./keyboard/global-handlers";
@@ -54,8 +55,8 @@ export function useAppKeyboard(
54
55
  stopPortForward: (id: string) => void,
55
56
  startPortForward: (options: StartPortForwardLifecycleOptions) => void,
56
57
  openPortForwardOverlay: () => void,
57
- toast: (tone: NotificationTone, message: string) => void,
58
- toastError: (error: unknown) => void,
58
+ toast: (tone: NotificationTone, message: string, options?: ToastOptions) => void,
59
+ toastError: (error: unknown, options?: ToastOptions) => void,
59
60
  copyToClipboard: (text: string, label?: string) => void,
60
61
  handleToggleReveal: (id: string) => void,
61
62
  logsScrollRef: React.MutableRefObject<ScrollBoxRenderable | null>,
@@ -115,6 +116,25 @@ export function useAppKeyboard(
115
116
  if (handleContainerPicker(key, state, dispatch, overlayIndex, activeDetail?.containers)) return;
116
117
  }
117
118
 
119
+ if (state.overlay === "notification-history") {
120
+ if (key.name === "c" && !key.shift) {
121
+ dispatch({ type: "clearNotificationHistory" });
122
+ return;
123
+ }
124
+ }
125
+
126
+ if (state.overlay === "port-forward-list") {
127
+ if (key.name === "x" && !key.shift) {
128
+ const forward = state.activePortForwards[overlayIndex];
129
+ if (forward) stopPortForward(forward.id);
130
+ return;
131
+ }
132
+ if (key.name === "c" && !key.shift) {
133
+ for (const f of state.activePortForwards) stopPortForward(f.id);
134
+ return;
135
+ }
136
+ }
137
+
118
138
  return;
119
139
  }
120
140
 
@@ -5,25 +5,21 @@ import type { AppAction } from "../app-actions";
5
5
  import type { AppState } from "../../lib/k8s/types";
6
6
  import type { ResourceRef } from "../../lib/k8s/types";
7
7
  import { savePersistedState } from "../persistence";
8
+ import { safeKill } from "../../lib/kubectl/spawn-utils";
8
9
 
9
10
  export function useAppSideEffects(
10
11
  state: AppState,
11
12
  dispatch: React.Dispatch<AppAction>,
12
13
  portForwardProcessesRef: React.MutableRefObject<Map<string, ChildProcess>>,
13
- notificationTimersRef: React.MutableRefObject<Map<string, ReturnType<typeof setTimeout>>>,
14
14
  activeResourceRef: ResourceRef | undefined,
15
15
  ) {
16
- // Kill all port-forward processes and cancel notification timers on unmount
16
+ // Kill all port-forward processes on unmount
17
17
  useEffect(() => {
18
18
  return () => {
19
19
  for (const child of portForwardProcessesRef.current.values()) {
20
- child.kill("SIGINT");
20
+ safeKill(child, "SIGINT");
21
21
  }
22
22
  portForwardProcessesRef.current.clear();
23
- for (const timer of notificationTimersRef.current.values()) {
24
- clearTimeout(timer);
25
- }
26
- notificationTimersRef.current.clear();
27
23
  };
28
24
  }, []);
29
25
 
@@ -36,4 +32,11 @@ export function useAppSideEffects(
36
32
  useEffect(() => {
37
33
  dispatch({ type: "setLogsContainer", container: undefined });
38
34
  }, [activeResourceRef?.kind, activeResourceRef?.name, activeResourceRef?.namespace]);
35
+
36
+ // Close port-forward-list overlay when no forwards remain
37
+ useEffect(() => {
38
+ if (state.overlay === "port-forward-list" && state.activePortForwards.length === 0) {
39
+ dispatch({ type: "setOverlay", overlay: undefined });
40
+ }
41
+ }, [state.overlay, state.activePortForwards.length]);
39
42
  }
@@ -3,11 +3,11 @@ import { useCallback } from "react";
3
3
 
4
4
  import type { AppAction } from "../app-actions";
5
5
  import type { NotificationTone } from "../../lib/k8s/types";
6
- import { loadError } from "../utils";
6
+ import type { ToastOptions } from "./use-notifications";
7
7
 
8
8
  export function useClipboard(
9
9
  dispatch: React.Dispatch<AppAction>,
10
- toast: (tone: NotificationTone, message: string) => void,
10
+ toast: (tone: NotificationTone, message: string, options?: ToastOptions) => void,
11
11
  ) {
12
12
  const copyToClipboard = useCallback((text: string, label?: string): void => {
13
13
  let cmd: string;
@@ -16,12 +16,12 @@ export function useClipboard(
16
16
  if (process.env.WAYLAND_DISPLAY) {
17
17
  cmd = "wl-copy";
18
18
  args = [];
19
- } else if (process.env.DISPLAY) {
20
- cmd = "xclip";
21
- args = ["-selection", "clipboard"];
22
19
  } else if (process.platform === "darwin") {
23
20
  cmd = "pbcopy";
24
21
  args = [];
22
+ } else if (process.env.DISPLAY) {
23
+ cmd = "xclip";
24
+ args = ["-selection", "clipboard"];
25
25
  } else {
26
26
  dispatch({ type: "setStatusMessage", message: "Clipboard not available on this platform" });
27
27
  return;
@@ -39,6 +39,8 @@ export function useClipboard(
39
39
  child.on("close", (code) => {
40
40
  if (code === 0) {
41
41
  toast("success", toastMessage);
42
+ } else {
43
+ dispatch({ type: "setStatusMessage", message: `Copy failed — is ${cmd} installed?` });
42
44
  }
43
45
  });
44
46
  } catch {
@@ -46,9 +48,5 @@ export function useClipboard(
46
48
  }
47
49
  }, [toast, dispatch]);
48
50
 
49
- const toastError = useCallback((error: unknown): void => {
50
- toast("danger", loadError(error).message);
51
- }, [toast]);
52
-
53
- return { copyToClipboard, toastError };
51
+ return { copyToClipboard };
54
52
  }
@@ -289,7 +289,7 @@ export function useDataFetching(
289
289
  useEffect(() => {
290
290
  const context = state.activeContext;
291
291
 
292
- if (!context || !activeResourceRef || !selectedKind || state.inspectorTab !== "events") {
292
+ if (!context || !activeResourceRef || !selectedKind) {
293
293
  return;
294
294
  }
295
295
 
@@ -333,27 +333,31 @@ export function useDataFetching(
333
333
  return () => {
334
334
  cancelled = true;
335
335
  };
336
- }, [activeResourceRef, selectedKind, state.activeContext, state.activeNamespace, state.inspectorTab, viewPollTick, manualRefreshNonce]);
336
+ }, [activeResourceRef, selectedKind, state.activeContext, state.activeNamespace, viewPollTick, manualRefreshNonce]);
337
337
 
338
338
  // Poll pod metrics when viewing pods (silent degradation on error)
339
339
  useEffect(() => {
340
+ let cancelled = false;
340
341
  const context = state.activeContext;
341
342
  if (!context || state.selectedKind !== "pods") return;
342
343
  void kubectl
343
344
  .getPodMetrics({ context, namespace: state.activeNamespace })
344
- .then((metrics) => dispatch({ type: "setPodMetrics", metrics }))
345
+ .then((metrics) => { if (!cancelled) dispatch({ type: "setPodMetrics", metrics }); })
345
346
  .catch(() => {/* metrics-server absent — silent degradation */});
346
- }, [state.activeContext, state.activeNamespace, state.selectedKind, metricsPollTick]);
347
+ return () => { cancelled = true; };
348
+ }, [state.activeContext, state.activeNamespace, state.selectedKind, metricsPollTick, manualRefreshNonce]);
347
349
 
348
350
  // Poll node metrics when viewing nodes (silent degradation on error)
349
351
  useEffect(() => {
352
+ let cancelled = false;
350
353
  const context = state.activeContext;
351
354
  if (!context || state.selectedKind !== "nodes") return;
352
355
  void kubectl
353
356
  .getNodeMetrics({ context })
354
- .then((metrics) => dispatch({ type: "setNodeMetrics", metrics }))
357
+ .then((metrics) => { if (!cancelled) dispatch({ type: "setNodeMetrics", metrics }); })
355
358
  .catch(() => {/* metrics-server absent — silent degradation */});
356
- }, [state.activeContext, state.selectedKind, metricsPollTick]);
359
+ return () => { cancelled = true; };
360
+ }, [state.activeContext, state.selectedKind, metricsPollTick, manualRefreshNonce]);
357
361
 
358
362
  return { resourcesLengthRef, selectedResourceNameRef, selectedResourceDetailRef, eventsLengthRef };
359
363
  }
@@ -5,6 +5,7 @@ import type { AppAction } from "../app-actions";
5
5
  import type { AppState, LoadStatus } from "../../lib/k8s/types";
6
6
  import { loadError } from "../utils";
7
7
  import { KubectlService } from "../../lib/kubectl/kubectl-service";
8
+ import { stopPersistentProcess } from "../../lib/kubectl/spawn-utils";
8
9
 
9
10
  export function useLogStream(
10
11
  dispatch: React.Dispatch<AppAction>,
@@ -21,7 +22,7 @@ export function useLogStream(
21
22
 
22
23
  if (state.overlay !== "logs" || !context || !activeLogsTarget || !activeLogsRef || !activeLogsKey) {
23
24
  if (logStreamRef.current) {
24
- logStreamRef.current.kill();
25
+ stopPersistentProcess(logStreamRef.current);
25
26
  logStreamRef.current = undefined;
26
27
  }
27
28
  return;
@@ -29,6 +30,7 @@ export function useLogStream(
29
30
 
30
31
  let cancelled = false;
31
32
  let flushTimer: ReturnType<typeof setTimeout> | undefined;
33
+ let errorSeen = false;
32
34
 
33
35
  try {
34
36
  const { target, child } = kubectl.streamLogs({
@@ -41,7 +43,7 @@ export function useLogStream(
41
43
  });
42
44
 
43
45
  if (logStreamRef.current) {
44
- logStreamRef.current.kill();
46
+ stopPersistentProcess(logStreamRef.current);
45
47
  }
46
48
 
47
49
  logStreamRef.current = child;
@@ -76,6 +78,7 @@ export function useLogStream(
76
78
  child.stderr?.on("data", (chunk: Buffer | string) => {
77
79
  if (cancelled) return;
78
80
  buffer += chunk.toString();
81
+ errorSeen = true;
79
82
  scheduleFlush("error");
80
83
  });
81
84
 
@@ -85,7 +88,8 @@ export function useLogStream(
85
88
  clearTimeout(flushTimer);
86
89
  flushTimer = undefined;
87
90
  }
88
- flush("ready");
91
+ // Preserve an error status if stderr produced output; only flush "ready" for clean exits.
92
+ flush(errorSeen ? "error" : "ready");
89
93
  });
90
94
  } catch (error) {
91
95
  const parsed = loadError(error);
@@ -103,7 +107,7 @@ export function useLogStream(
103
107
  clearTimeout(flushTimer);
104
108
  }
105
109
  if (logStreamRef.current) {
106
- logStreamRef.current.kill();
110
+ stopPersistentProcess(logStreamRef.current);
107
111
  logStreamRef.current = undefined;
108
112
  }
109
113
  };
@@ -0,0 +1,92 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+
3
+ import type { AppAction } from "../app-actions";
4
+ import type { NotificationAction, NotificationTone } from "../../lib/k8s/types";
5
+ import { loadError } from "../utils";
6
+ import { TOAST_DURATION_MS } from "../constants";
7
+
8
+ export interface ToastAction {
9
+ label: string;
10
+ onAction: () => void;
11
+ }
12
+
13
+ export interface ToastOptions {
14
+ persistent?: boolean | undefined;
15
+ actions?: ToastAction[] | undefined;
16
+ }
17
+
18
+ export interface UseNotificationsResult {
19
+ toast: (tone: NotificationTone, message: string, options?: ToastOptions) => string;
20
+ toastError: (error: unknown, options?: ToastOptions) => string;
21
+ dismiss: (id: string) => void;
22
+ executeAction: (notificationId: string, actionId: string) => void;
23
+ }
24
+
25
+ export function useNotifications(dispatch: React.Dispatch<AppAction>): UseNotificationsResult {
26
+ const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
27
+ const actionsRef = useRef(new Map<string, Map<string, () => void>>());
28
+
29
+ const clearTimer = useCallback((id: string): void => {
30
+ const timer = timersRef.current.get(id);
31
+ if (timer) {
32
+ clearTimeout(timer);
33
+ timersRef.current.delete(id);
34
+ }
35
+ }, []);
36
+
37
+ const dismiss = useCallback((id: string): void => {
38
+ clearTimer(id);
39
+ actionsRef.current.delete(id);
40
+ dispatch({ type: "dismissNotification", id });
41
+ }, [clearTimer, dispatch]);
42
+
43
+ const toast = useCallback((tone: NotificationTone, message: string, options?: ToastOptions): string => {
44
+ const id = `notif-${crypto.randomUUID()}`;
45
+ const persistent = options?.persistent ?? (tone === "danger");
46
+ const actions: NotificationAction[] = (options?.actions ?? []).map((a, i) => ({
47
+ id: `action-${i}`,
48
+ label: a.label,
49
+ }));
50
+
51
+ if (options?.actions && options.actions.length > 0) {
52
+ const actionMap = new Map<string, () => void>();
53
+ options.actions.forEach((a, i) => actionMap.set(`action-${i}`, a.onAction));
54
+ actionsRef.current.set(id, actionMap);
55
+ }
56
+
57
+ dispatch({
58
+ type: "pushNotification",
59
+ notification: { id, tone, message, persistent, createdAt: Date.now(), actions },
60
+ });
61
+
62
+ if (!persistent) {
63
+ const timer = setTimeout(() => dismiss(id), TOAST_DURATION_MS);
64
+ timersRef.current.set(id, timer);
65
+ }
66
+
67
+ return id;
68
+ }, [dispatch, dismiss]);
69
+
70
+ const toastError = useCallback((error: unknown, options?: ToastOptions): string => {
71
+ return toast("danger", loadError(error).message, options);
72
+ }, [toast]);
73
+
74
+ const executeAction = useCallback((notificationId: string, actionId: string): void => {
75
+ const actionMap = actionsRef.current.get(notificationId);
76
+ const callback = actionMap?.get(actionId);
77
+ if (callback) callback();
78
+ dismiss(notificationId);
79
+ }, [dismiss]);
80
+
81
+ useEffect(() => {
82
+ return () => {
83
+ for (const timer of timersRef.current.values()) {
84
+ clearTimeout(timer);
85
+ }
86
+ timersRef.current.clear();
87
+ actionsRef.current.clear();
88
+ };
89
+ }, []);
90
+
91
+ return { toast, toastError, dismiss, executeAction };
92
+ }
@@ -6,11 +6,13 @@ import type { ActivePortForward, ResourceDetail, ResourceRef } from "../../lib/k
6
6
  import type { StartPortForwardLifecycleOptions } from "../utils";
7
7
  import { portForwardId, statusLine } from "../utils";
8
8
  import { KubectlService } from "../../lib/kubectl/kubectl-service";
9
+ import { safeKill } from "../../lib/kubectl/spawn-utils";
10
+ import type { ToastOptions } from "./use-notifications";
9
11
 
10
12
  export function usePortForward(
11
13
  dispatch: React.Dispatch<AppAction>,
12
14
  kubectl: KubectlService,
13
- toastError: (error: unknown) => void,
15
+ toastError: (error: unknown, options?: ToastOptions) => void,
14
16
  ) {
15
17
  const portForwardProcessesRef = useRef(new Map<string, ChildProcess>());
16
18
 
@@ -22,7 +24,7 @@ export function usePortForward(
22
24
  return;
23
25
  }
24
26
 
25
- child.kill("SIGINT");
27
+ safeKill(child, "SIGINT");
26
28
  };
27
29
 
28
30
  const openPortForwardOverlay = (
@@ -108,7 +110,21 @@ export function usePortForward(
108
110
  child.stdout?.on("data", handleOutput);
109
111
  child.stderr?.on("data", handleOutput);
110
112
 
113
+ let handled = false;
114
+ const reportFailure = (): void => {
115
+ if (handled) return;
116
+ handled = true;
117
+ portForwardProcessesRef.current.delete(id);
118
+ dispatch({ type: "removeActivePortForward", id });
119
+ };
120
+
111
121
  child.on("close", (code, signal) => {
122
+ if (handled) {
123
+ portForwardProcessesRef.current.delete(id);
124
+ dispatch({ type: "removeActivePortForward", id });
125
+ return;
126
+ }
127
+ handled = true;
112
128
  portForwardProcessesRef.current.delete(id);
113
129
  dispatch({ type: "removeActivePortForward", id });
114
130
 
@@ -135,8 +151,7 @@ export function usePortForward(
135
151
  });
136
152
 
137
153
  child.on("error", (error) => {
138
- portForwardProcessesRef.current.delete(id);
139
- dispatch({ type: "removeActivePortForward", id });
154
+ reportFailure();
140
155
  toastError(error);
141
156
  dispatch({
142
157
  type: "setStatusMessage",
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join, dirname } from "node:path";
3
4
 
4
5
  export interface PersistedAppState {
@@ -7,7 +8,7 @@ export interface PersistedAppState {
7
8
  }
8
9
 
9
10
  function configPath(): string {
10
- const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
11
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir();
11
12
  return join(home, ".config", "openk8s", "state.json");
12
13
  }
13
14
 
@@ -23,9 +24,12 @@ export function loadPersistedState(): PersistedAppState {
23
24
 
24
25
  const data = parsed as Record<string, unknown>;
25
26
 
27
+ const isNonEmptyString = (value: unknown): value is string =>
28
+ typeof value === "string" && value.length > 0;
29
+
26
30
  return {
27
- activeContext: typeof data.activeContext === "string" ? data.activeContext : undefined,
28
- activeNamespace: typeof data.activeNamespace === "string" ? data.activeNamespace : undefined,
31
+ activeContext: isNonEmptyString(data.activeContext) ? data.activeContext : undefined,
32
+ activeNamespace: isNonEmptyString(data.activeNamespace) ? data.activeNamespace : undefined,
29
33
  };
30
34
  } catch {
31
35
  return {};
@@ -0,0 +1,31 @@
1
+ import { RGBA, SyntaxStyle } from "@opentui/core";
2
+
3
+ const C = {
4
+ property: "#7aa2f7",
5
+ string: "#9ece6a",
6
+ number: "#ff9e64",
7
+ boolean: "#ff9e64",
8
+ constant: "#ff9e64",
9
+ comment: "#4e5f88",
10
+ label: "#bb9af7",
11
+ type: "#f7768e",
12
+ attribute: "#7dcfff",
13
+ punctuation: "#89ddff",
14
+ default: "#d4e1f7",
15
+ };
16
+
17
+ export const yamlSyntaxStyle = SyntaxStyle.fromStyles({
18
+ property: { fg: RGBA.fromHex(C.property) },
19
+ string: { fg: RGBA.fromHex(C.string) },
20
+ number: { fg: RGBA.fromHex(C.number) },
21
+ boolean: { fg: RGBA.fromHex(C.boolean) },
22
+ "constant.builtin": { fg: RGBA.fromHex(C.constant) },
23
+ comment: { fg: RGBA.fromHex(C.comment), dim: true },
24
+ label: { fg: RGBA.fromHex(C.label) },
25
+ type: { fg: RGBA.fromHex(C.type) },
26
+ attribute: { fg: RGBA.fromHex(C.attribute) },
27
+ "punctuation.delimiter": { fg: RGBA.fromHex(C.punctuation) },
28
+ "punctuation.bracket": { fg: RGBA.fromHex(C.punctuation) },
29
+ "punctuation.special": { fg: RGBA.fromHex(C.punctuation) },
30
+ default: { fg: RGBA.fromHex(C.default) },
31
+ });
package/src/app/theme.ts CHANGED
@@ -26,7 +26,8 @@ export const ROW_SELECTED = "#152040";
26
26
  export const ROW_SELECTED_ALT = "#111c38";
27
27
 
28
28
  // ── Text ──────────────────────────────────────────────────────────────────────
29
- export const TEXT_MUTED = TextAttributes.DIM;
29
+ // Note: ATTR_DIM is a TextAttributes value (not a color); use it via the `attributes` prop.
30
+ export const ATTR_DIM = TextAttributes.DIM;
30
31
  export const TEXT_PRIMARY = "#d4e1f7";
31
32
  export const TEXT_SUBTLE = "#6474a0";
32
33
  export const FILTER_BACKGROUND = "#090e1a";
@@ -42,8 +43,6 @@ export const ACCENT = "#5b8edb";
42
43
  export const KEY_HINT = "#5b8edb";
43
44
 
44
45
  // ── YAML colorizer ────────────────────────────────────────────────────────────
45
- export const YAML_KEY = "#7aa2f7";
46
- export const YAML_VALUE = "#9ece6a";
47
46
  export const YAML_COMMENT = "#4e5f88";
48
47
 
49
48
  // ── Unicode glyphs ────────────────────────────────────────────────────────────