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.
- package/README.md +1 -1
- package/package.json +13 -10
- package/src/app/__tests__/app-state.test.ts +48 -6
- package/src/app/__tests__/utils.test.ts +6 -15
- 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 +40 -83
- 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 +171 -0
- package/src/app/components/overlays/index.ts +4 -1
- package/src/app/components/overlays/logs-dialog.tsx +47 -67
- 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 +30 -21
- 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 +32 -16
- 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 +10 -6
- 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-detail-builder.test.ts +4 -6
- package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
- package/src/lib/k8s/detail-builders/event-builder.ts +18 -12
- 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 +4 -7
- package/src/lib/k8s/resource-parser.ts +17 -2
- package/src/lib/k8s/types.ts +12 -4
- 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 +38 -54
- 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,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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
89
|
+
const isHelm = isHelmRelease(target);
|
|
88
90
|
|
|
89
91
|
if (isHelm) {
|
|
90
92
|
const chartName = activeDetail?.helmChart ?? "";
|
|
91
|
-
const values = activeDetail?.
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -289,7 +289,7 @@ export function useDataFetching(
|
|
|
289
289
|
useEffect(() => {
|
|
290
290
|
const context = state.activeContext;
|
|
291
291
|
|
|
292
|
-
if (!context || !activeResourceRef || !selectedKind
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ────────────────────────────────────────────────────────────
|