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.
- package/README.md +1 -1
- package/package.json +11 -10
- package/src/app/__tests__/app-state.test.ts +47 -5
- package/src/app/__tests__/utils.test.ts +5 -14
- package/src/app/app-actions.ts +5 -0
- package/src/app/app-state.ts +11 -2
- package/src/app/app.tsx +57 -46
- package/src/app/components/detail-sections.tsx +2 -2
- package/src/app/components/footer.tsx +23 -30
- package/src/app/components/inspector.tsx +28 -43
- package/src/app/components/kind-rows.tsx +1 -1
- package/src/app/components/notification-card.tsx +145 -0
- package/src/app/components/notification-tray.tsx +19 -38
- package/src/app/components/overlays/delete-confirm-overlay.tsx +15 -17
- package/src/app/components/overlays/helm-rollback-overlay.tsx +12 -66
- package/src/app/components/overlays/help-overlay.tsx +173 -0
- package/src/app/components/overlays/index.ts +5 -2
- package/src/app/components/overlays/logs-dialog.tsx +50 -80
- package/src/app/components/overlays/notification-history-overlay.tsx +79 -0
- package/src/app/components/overlays/port-forward-list-overlay.tsx +85 -0
- package/src/app/components/overlays/port-forward-overlay.tsx +16 -56
- package/src/app/components/overlays/scale-dialog.tsx +12 -67
- package/src/app/components/overlays/select-overlay.tsx +5 -14
- package/src/app/components/overlays/shared.tsx +85 -6
- package/src/app/components/resource-rows.tsx +1 -1
- package/src/app/constants.ts +24 -0
- package/src/app/hooks/keyboard/filter-handlers.ts +2 -1
- package/src/app/hooks/keyboard/global-handlers.ts +32 -11
- package/src/app/hooks/keyboard/helm-handlers.ts +14 -9
- package/src/app/hooks/keyboard/keys.ts +18 -0
- package/src/app/hooks/keyboard/logs-handlers.ts +3 -1
- package/src/app/hooks/keyboard/navigation-handlers.ts +5 -4
- package/src/app/hooks/keyboard/overlay-handlers.ts +11 -7
- package/src/app/hooks/keyboard/port-forward-handlers.ts +19 -14
- package/src/app/hooks/keyboard/shell-edit-handlers.ts +45 -17
- package/src/app/hooks/use-app-keyboard.ts +22 -2
- package/src/app/hooks/use-app-side-effects.ts +10 -7
- package/src/app/hooks/use-clipboard.ts +8 -10
- package/src/app/hooks/use-data-fetching.ts +11 -5
- package/src/app/hooks/use-log-stream.ts +8 -4
- package/src/app/hooks/use-notifications.ts +92 -0
- package/src/app/hooks/use-port-forward.ts +19 -4
- package/src/app/persistence.ts +7 -3
- package/src/app/syntax-theme.ts +31 -0
- package/src/app/theme.ts +2 -3
- package/src/app/use-footer-hints.ts +21 -16
- package/src/app/utils.ts +1 -9
- package/src/assets/tree-sitter/yaml/highlights.scm +79 -0
- package/src/assets/tree-sitter/yaml/tree-sitter-yaml.wasm +0 -0
- package/src/index.tsx +22 -2
- package/src/lib/k8s/__tests__/k8s-format.test.ts +17 -0
- package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
- package/src/lib/k8s/detail-builders/event-builder.ts +17 -11
- package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +34 -31
- package/src/lib/k8s/detail-builders/node-builder.ts +22 -11
- package/src/lib/k8s/detail-builders/overview-builder.ts +4 -17
- package/src/lib/k8s/detail-builders/pod-builder.ts +52 -24
- package/src/lib/k8s/detail-builders/rbac-builder.ts +20 -29
- package/src/lib/k8s/k8s-format.ts +14 -9
- package/src/lib/k8s/resource-detail-builder.ts +1 -1
- package/src/lib/k8s/resource-parser.ts +17 -2
- package/src/lib/k8s/types.ts +10 -1
- package/src/lib/kubectl/__tests__/metrics-utils.test.ts +9 -0
- package/src/lib/kubectl/kubectl-helpers.ts +16 -11
- package/src/lib/kubectl/kubectl-service.ts +39 -39
- package/src/lib/kubectl/kubectl-types.ts +10 -1
- package/src/lib/kubectl/metrics-utils.ts +21 -7
- package/src/lib/kubectl/spawn-utils.ts +50 -11
- package/src/app/__tests__/components/inspector-tokens.test.ts +0 -101
- package/src/app/components/inspector-tokens.ts +0 -93
- package/src/app/components/port-forwards-tray.tsx +0 -57
|
@@ -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
|
|
25
|
+
if (isHelmRelease(activeResourceRef)) {
|
|
24
26
|
dispatch({ type: "setStatusMessage", message: "Shell not supported for Helm releases" });
|
|
25
27
|
return true;
|
|
26
28
|
}
|
|
@@ -35,10 +37,22 @@ export function handleShellKeys(
|
|
|
35
37
|
const target = activeResourceRef;
|
|
36
38
|
|
|
37
39
|
const child = kubectl.startShell({ context, namespace, resourceRef: target, namespaced });
|
|
38
|
-
|
|
40
|
+
|
|
41
|
+
let stderrBuffer = "";
|
|
42
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
43
|
+
stderrBuffer += chunk.toString();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
child.on("close", (code) => {
|
|
39
47
|
renderer.resume();
|
|
40
48
|
dispatch({ type: "setError", error: undefined });
|
|
41
|
-
|
|
49
|
+
if (code === 0) {
|
|
50
|
+
dispatch({ type: "setStatusMessage", message: `Returned from shell in ${statusLine({ ref: target })}` });
|
|
51
|
+
} else {
|
|
52
|
+
const msg = stderrBuffer.trim() || `Shell exited with code ${code}`;
|
|
53
|
+
toastError(new Error(msg));
|
|
54
|
+
dispatch({ type: "setStatusMessage", message: `Shell failed for ${statusLine({ ref: target })}` });
|
|
55
|
+
}
|
|
42
56
|
setManualRefreshNonce((current) => current + 1);
|
|
43
57
|
});
|
|
44
58
|
child.on("error", (error) => {
|
|
@@ -61,8 +75,8 @@ export function handleEditKeys(
|
|
|
61
75
|
selectedKind: ResourceKind | undefined,
|
|
62
76
|
activeDetail: ResourceDetail | undefined,
|
|
63
77
|
kubectl: KubectlService,
|
|
64
|
-
toast: (tone: NotificationTone, message: string) => void,
|
|
65
|
-
toastError: (error: unknown) => void,
|
|
78
|
+
toast: (tone: NotificationTone, message: string, options?: ToastOptions) => void,
|
|
79
|
+
toastError: (error: unknown, options?: ToastOptions) => void,
|
|
66
80
|
setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
|
|
67
81
|
): boolean {
|
|
68
82
|
if (state.overlay) return false;
|
|
@@ -72,7 +86,7 @@ export function handleEditKeys(
|
|
|
72
86
|
const namespace = state.activeNamespace;
|
|
73
87
|
const namespaced = selectedKind.namespaced;
|
|
74
88
|
const target = activeResourceRef;
|
|
75
|
-
const isHelm = target
|
|
89
|
+
const isHelm = isHelmRelease(target);
|
|
76
90
|
|
|
77
91
|
if (isHelm) {
|
|
78
92
|
const chartName = activeDetail?.helmChart ?? "";
|
|
@@ -99,7 +113,10 @@ export function handleEditKeys(
|
|
|
99
113
|
const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
|
|
100
114
|
const editorChild = spawn(editor, [tmpPath], { env: process.env, stdio: "inherit" });
|
|
101
115
|
|
|
102
|
-
|
|
116
|
+
let editHandled = false;
|
|
117
|
+
const finishEdit = (code: number | null): void => {
|
|
118
|
+
if (editHandled) return;
|
|
119
|
+
editHandled = true;
|
|
103
120
|
if (code === 0) {
|
|
104
121
|
kubectl
|
|
105
122
|
.helmUpgradeValues({ context, namespace, name: target.name, chart: chartName, valuesFile: tmpPath })
|
|
@@ -118,9 +135,15 @@ export function handleEditKeys(
|
|
|
118
135
|
dispatch({ type: "setStatusMessage", message: "Edit cancelled" });
|
|
119
136
|
cleanupTmp();
|
|
120
137
|
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
editorChild.on("close", (code) => {
|
|
141
|
+
finishEdit(code);
|
|
121
142
|
});
|
|
122
143
|
|
|
123
144
|
editorChild.on("error", (err) => {
|
|
145
|
+
if (editHandled) return;
|
|
146
|
+
editHandled = true;
|
|
124
147
|
renderer.resume();
|
|
125
148
|
toastError(err);
|
|
126
149
|
cleanupTmp();
|
|
@@ -132,7 +155,10 @@ export function handleEditKeys(
|
|
|
132
155
|
|
|
133
156
|
const editChild = kubectl.editResource({ context, namespace, resourceRef: target, namespaced });
|
|
134
157
|
|
|
158
|
+
let editClosed = false;
|
|
135
159
|
editChild.on("close", (code) => {
|
|
160
|
+
if (editClosed) return;
|
|
161
|
+
editClosed = true;
|
|
136
162
|
renderer.resume();
|
|
137
163
|
if (code === 0) {
|
|
138
164
|
dispatch({ type: "setStatusMessage", message: `Saved ${statusLine({ ref: target })}` });
|
|
@@ -143,6 +169,8 @@ export function handleEditKeys(
|
|
|
143
169
|
});
|
|
144
170
|
|
|
145
171
|
editChild.on("error", (err) => {
|
|
172
|
+
if (editClosed) return;
|
|
173
|
+
editClosed = true;
|
|
146
174
|
renderer.resume();
|
|
147
175
|
toastError(err);
|
|
148
176
|
dispatch({ type: "setStatusMessage", message: `Edit failed for ${statusLine({ ref: target })}` });
|
|
@@ -151,11 +179,6 @@ export function handleEditKeys(
|
|
|
151
179
|
return true;
|
|
152
180
|
}
|
|
153
181
|
|
|
154
|
-
// Shift+S → scale dialog
|
|
155
|
-
if (key.shift && key.name === "s" && activeResourceRef && selectedKind && state.activeContext && kubectl.canScale(activeResourceRef)) {
|
|
156
|
-
return false; // handled by scale handler
|
|
157
|
-
}
|
|
158
|
-
|
|
159
182
|
return false;
|
|
160
183
|
}
|
|
161
184
|
|
|
@@ -167,11 +190,16 @@ export function handleScaleKeys(
|
|
|
167
190
|
selectedKind: ResourceKind | undefined,
|
|
168
191
|
scaleReplicasInput: string,
|
|
169
192
|
kubectl: KubectlService,
|
|
170
|
-
toastError: (error: unknown) => void,
|
|
193
|
+
toastError: (error: unknown, options?: ToastOptions) => void,
|
|
171
194
|
setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
|
|
172
195
|
): boolean {
|
|
173
196
|
if (state.overlay !== "scale") return false;
|
|
174
197
|
|
|
198
|
+
if (key.name === "escape") {
|
|
199
|
+
dispatch({ type: "setOverlay", overlay: undefined });
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
175
203
|
if (key.name === "return" && activeResourceRef && selectedKind && state.activeContext) {
|
|
176
204
|
const replicas = Number.parseInt(scaleReplicasInput, 10);
|
|
177
205
|
|
|
@@ -195,7 +223,7 @@ export function handleScaleKeys(
|
|
|
195
223
|
setManualRefreshNonce((current) => current + 1);
|
|
196
224
|
})
|
|
197
225
|
.catch((error: unknown) => {
|
|
198
|
-
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); } }] });
|
|
199
227
|
dispatch({
|
|
200
228
|
type: "setStatusMessage",
|
|
201
229
|
message: `Scale failed for ${statusLine({ ref: target })}`,
|
|
@@ -215,7 +243,7 @@ export function handleRolloutRestartKeys(
|
|
|
215
243
|
activeResourceRef: ResourceRef | undefined,
|
|
216
244
|
selectedKind: ResourceKind | undefined,
|
|
217
245
|
kubectl: KubectlService,
|
|
218
|
-
toastError: (error: unknown) => void,
|
|
246
|
+
toastError: (error: unknown, options?: ToastOptions) => void,
|
|
219
247
|
setManualRefreshNonce: React.Dispatch<React.SetStateAction<number>>,
|
|
220
248
|
): boolean {
|
|
221
249
|
if (state.overlay) return false;
|
|
@@ -237,7 +265,7 @@ export function handleRolloutRestartKeys(
|
|
|
237
265
|
setManualRefreshNonce((current) => current + 1);
|
|
238
266
|
})
|
|
239
267
|
.catch((error: unknown) => {
|
|
240
|
-
toastError(error);
|
|
268
|
+
toastError(error, { actions: [{ label: "retry", onAction: () => { void kubectl.rolloutRestart({ context, namespace, resourceRef: target, namespaced }).then(() => setManualRefreshNonce((c) => c + 1)).catch(toastError); } }] });
|
|
241
269
|
dispatch({
|
|
242
270
|
type: "setStatusMessage",
|
|
243
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
50
|
-
toast("danger", loadError(error).message);
|
|
51
|
-
}, [toast]);
|
|
52
|
-
|
|
53
|
-
return { copyToClipboard, toastError };
|
|
51
|
+
return { copyToClipboard };
|
|
54
52
|
}
|
|
@@ -183,7 +183,9 @@ export function useDataFetching(
|
|
|
183
183
|
let cancelled = false;
|
|
184
184
|
|
|
185
185
|
async function loadResources(): Promise<void> {
|
|
186
|
-
|
|
186
|
+
if (resourcesLengthRef.current === 0) {
|
|
187
|
+
dispatch({ type: "setResourcesStatus", status: "loading" });
|
|
188
|
+
}
|
|
187
189
|
|
|
188
190
|
try {
|
|
189
191
|
const resources = await kubectl.listResources({
|
|
@@ -335,23 +337,27 @@ export function useDataFetching(
|
|
|
335
337
|
|
|
336
338
|
// Poll pod metrics when viewing pods (silent degradation on error)
|
|
337
339
|
useEffect(() => {
|
|
340
|
+
let cancelled = false;
|
|
338
341
|
const context = state.activeContext;
|
|
339
342
|
if (!context || state.selectedKind !== "pods") return;
|
|
340
343
|
void kubectl
|
|
341
344
|
.getPodMetrics({ context, namespace: state.activeNamespace })
|
|
342
|
-
.then((metrics) => dispatch({ type: "setPodMetrics", metrics }))
|
|
345
|
+
.then((metrics) => { if (!cancelled) dispatch({ type: "setPodMetrics", metrics }); })
|
|
343
346
|
.catch(() => {/* metrics-server absent — silent degradation */});
|
|
344
|
-
|
|
347
|
+
return () => { cancelled = true; };
|
|
348
|
+
}, [state.activeContext, state.activeNamespace, state.selectedKind, metricsPollTick, manualRefreshNonce]);
|
|
345
349
|
|
|
346
350
|
// Poll node metrics when viewing nodes (silent degradation on error)
|
|
347
351
|
useEffect(() => {
|
|
352
|
+
let cancelled = false;
|
|
348
353
|
const context = state.activeContext;
|
|
349
354
|
if (!context || state.selectedKind !== "nodes") return;
|
|
350
355
|
void kubectl
|
|
351
356
|
.getNodeMetrics({ context })
|
|
352
|
-
.then((metrics) => dispatch({ type: "setNodeMetrics", metrics }))
|
|
357
|
+
.then((metrics) => { if (!cancelled) dispatch({ type: "setNodeMetrics", metrics }); })
|
|
353
358
|
.catch(() => {/* metrics-server absent — silent degradation */});
|
|
354
|
-
|
|
359
|
+
return () => { cancelled = true; };
|
|
360
|
+
}, [state.activeContext, state.selectedKind, metricsPollTick, manualRefreshNonce]);
|
|
355
361
|
|
|
356
362
|
return { resourcesLengthRef, selectedResourceNameRef, selectedResourceDetailRef, eventsLengthRef };
|
|
357
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
139
|
-
dispatch({ type: "removeActivePortForward", id });
|
|
154
|
+
reportFailure();
|
|
140
155
|
toastError(error);
|
|
141
156
|
dispatch({
|
|
142
157
|
type: "setStatusMessage",
|
package/src/app/persistence.ts
CHANGED
|
@@ -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:
|
|
28
|
-
activeNamespace:
|
|
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
|
-
|
|
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 ────────────────────────────────────────────────────────────
|