openk8s 0.0.1 → 1.0.1
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 +187 -36
- package/bin/openk8s.js +2 -0
- package/package.json +52 -6
- package/src/app/app-state.ts +461 -0
- package/src/app/app.tsx +708 -0
- package/src/app/components/inspector.tsx +449 -0
- package/src/app/components/kind-rows.tsx +66 -0
- package/src/app/components/notification-tray.tsx +59 -0
- package/src/app/components/overlays/delete-confirm-overlay.tsx +79 -0
- package/src/app/components/overlays/helm-rollback-overlay.tsx +86 -0
- package/src/app/components/overlays/index.ts +12 -0
- package/src/app/components/overlays/logs-dialog.tsx +303 -0
- package/src/app/components/overlays/port-forward-overlay.tsx +184 -0
- package/src/app/components/overlays/scale-dialog.tsx +96 -0
- package/src/app/components/overlays/select-overlay.tsx +68 -0
- package/src/app/components/overlays/shared.tsx +18 -0
- package/src/app/components/port-forwards-tray.tsx +57 -0
- package/src/app/components/resource-rows.tsx +120 -0
- package/src/app/hooks/use-app-keyboard.ts +723 -0
- package/src/app/hooks/use-app-side-effects.ts +39 -0
- package/src/app/hooks/use-clipboard.ts +54 -0
- package/src/app/hooks/use-data-fetching.ts +366 -0
- package/src/app/hooks/use-log-stream.ts +113 -0
- package/src/app/hooks/use-port-forward.ts +149 -0
- package/src/app/persistence.ts +44 -0
- package/src/app/theme.ts +95 -0
- package/src/app/use-polling-tick.ts +27 -0
- package/src/app/utils.ts +274 -0
- package/src/index.tsx +8 -0
- package/src/lib/k8s/k8s-format.ts +42 -0
- package/src/lib/k8s/resource-detail-builder.ts +545 -0
- package/src/lib/k8s/resource-parser.ts +308 -0
- package/src/lib/k8s/types.ts +164 -0
- package/src/lib/kubectl/kubectl-service.ts +1116 -0
- package/src/lib/kubectl/spawn-utils.ts +81 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
FILTER_BACKGROUND,
|
|
5
|
+
GLYPHS,
|
|
6
|
+
KEY_HINT,
|
|
7
|
+
OVERLAY_SURFACE,
|
|
8
|
+
PANEL_BORDER_ACTIVE,
|
|
9
|
+
TEXT_MUTED,
|
|
10
|
+
TEXT_SUBTLE,
|
|
11
|
+
toneStyles,
|
|
12
|
+
} from "../../theme";
|
|
13
|
+
import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH } from "./shared";
|
|
14
|
+
|
|
15
|
+
export interface HelmRollbackOverlayProps {
|
|
16
|
+
releaseName: string;
|
|
17
|
+
revisionValue: string;
|
|
18
|
+
onChange: (value: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function HelmRollbackOverlay({ releaseName, revisionValue, onChange }: HelmRollbackOverlayProps) {
|
|
22
|
+
const palette = toneStyles("warning");
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<box
|
|
26
|
+
style={{
|
|
27
|
+
position: "absolute",
|
|
28
|
+
left: DIALOG_LEFT,
|
|
29
|
+
top: DIALOG_TOP,
|
|
30
|
+
width: DIALOG_WIDTH,
|
|
31
|
+
height: 12,
|
|
32
|
+
border: true,
|
|
33
|
+
borderStyle: "rounded",
|
|
34
|
+
borderColor: palette.border,
|
|
35
|
+
backgroundColor: OVERLAY_SURFACE,
|
|
36
|
+
padding: 1,
|
|
37
|
+
flexDirection: "column",
|
|
38
|
+
}}
|
|
39
|
+
title="Helm Rollback"
|
|
40
|
+
>
|
|
41
|
+
<text fg={palette.fg} attributes={TextAttributes.BOLD}>
|
|
42
|
+
{releaseName}
|
|
43
|
+
</text>
|
|
44
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
45
|
+
{"Leave revision empty to roll back to previous version"}
|
|
46
|
+
</text>
|
|
47
|
+
<box
|
|
48
|
+
style={{
|
|
49
|
+
border: true,
|
|
50
|
+
borderColor: PANEL_BORDER_ACTIVE,
|
|
51
|
+
borderStyle: "rounded",
|
|
52
|
+
backgroundColor: FILTER_BACKGROUND,
|
|
53
|
+
paddingLeft: 1,
|
|
54
|
+
paddingRight: 1,
|
|
55
|
+
marginTop: 1,
|
|
56
|
+
height: 3,
|
|
57
|
+
justifyContent: "center",
|
|
58
|
+
alignItems: "center",
|
|
59
|
+
flexDirection: "row",
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
63
|
+
Revision:
|
|
64
|
+
</text>
|
|
65
|
+
<input
|
|
66
|
+
focused
|
|
67
|
+
value={revisionValue}
|
|
68
|
+
placeholder="(previous)"
|
|
69
|
+
style={{ flexGrow: 1, marginLeft: 1 }}
|
|
70
|
+
onInput={onChange}
|
|
71
|
+
onChange={onChange}
|
|
72
|
+
/>
|
|
73
|
+
</box>
|
|
74
|
+
<box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
|
|
75
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
76
|
+
<span fg={KEY_HINT}>{GLYPHS.enter}</span>
|
|
77
|
+
{" rollback"}
|
|
78
|
+
</text>
|
|
79
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
80
|
+
<span fg={KEY_HINT}>{GLYPHS.esc}</span>
|
|
81
|
+
{" cancel"}
|
|
82
|
+
</text>
|
|
83
|
+
</box>
|
|
84
|
+
</box>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH, KeyHintRow } from "./shared";
|
|
2
|
+
export { SelectOverlay, type SelectOverlayProps } from "./select-overlay";
|
|
3
|
+
export { DeleteConfirmOverlay, type DeleteConfirmOverlayProps } from "./delete-confirm-overlay";
|
|
4
|
+
export {
|
|
5
|
+
PortForwardOverlay,
|
|
6
|
+
type PortForwardEntry,
|
|
7
|
+
type PortForwardOverlayProps,
|
|
8
|
+
buildPortForwardEntries,
|
|
9
|
+
} from "./port-forward-overlay";
|
|
10
|
+
export { LogsDialog, type LogsDialogActions, type LogsDialogProps, logLineColor } from "./logs-dialog";
|
|
11
|
+
export { ScaleDialog, type ScaleDialogProps } from "./scale-dialog";
|
|
12
|
+
export { HelmRollbackOverlay, type HelmRollbackOverlayProps } from "./helm-rollback-overlay";
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
FILTER_BACKGROUND,
|
|
6
|
+
GLYPHS,
|
|
7
|
+
KEY_HINT,
|
|
8
|
+
OVERLAY_SURFACE,
|
|
9
|
+
PANEL_BORDER_ACTIVE,
|
|
10
|
+
TEXT_MUTED,
|
|
11
|
+
TEXT_SUBTLE,
|
|
12
|
+
YAML_COMMENT,
|
|
13
|
+
toneStyles,
|
|
14
|
+
} from "../../theme";
|
|
15
|
+
import type { LoadStatus, LogOptions } from "../../../lib/k8s/types";
|
|
16
|
+
|
|
17
|
+
function logLineColor(line: string): string {
|
|
18
|
+
const lower = line.toLowerCase();
|
|
19
|
+
if (/\b(error|err|fatal|panic|critical)\b/.test(lower)) return toneStyles("danger").fg;
|
|
20
|
+
if (/\bwarn(?:ing)?\b/.test(lower)) return toneStyles("warning").fg;
|
|
21
|
+
if (/\binfo(?:rmation)?\b/.test(lower)) return toneStyles("info").fg;
|
|
22
|
+
if (/\bdebug\b/.test(lower)) return TEXT_SUBTLE;
|
|
23
|
+
if (/\btrace\b/.test(lower)) return YAML_COMMENT;
|
|
24
|
+
return TEXT_SUBTLE;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LogsDialogActions {
|
|
28
|
+
nextMatch: () => void;
|
|
29
|
+
prevMatch: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LogsDialogProps {
|
|
33
|
+
target?: string | undefined;
|
|
34
|
+
logsText: string;
|
|
35
|
+
status: LoadStatus;
|
|
36
|
+
scrollRef: { current: ScrollBoxRenderable | null };
|
|
37
|
+
logsOptions: LogOptions;
|
|
38
|
+
logsContainer?: string | undefined;
|
|
39
|
+
containers?: string[] | undefined;
|
|
40
|
+
inputMode?: "tail" | "since" | undefined;
|
|
41
|
+
inputValue?: string | undefined;
|
|
42
|
+
onOptionInputChange?: ((value: string) => void) | undefined;
|
|
43
|
+
searchMode?: boolean | undefined;
|
|
44
|
+
searchText?: string | undefined;
|
|
45
|
+
onSearchChange?: ((value: string) => void) | undefined;
|
|
46
|
+
actionsRef?: React.MutableRefObject<LogsDialogActions | null> | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function LogsDialog({
|
|
50
|
+
target,
|
|
51
|
+
logsText,
|
|
52
|
+
status,
|
|
53
|
+
scrollRef,
|
|
54
|
+
logsOptions,
|
|
55
|
+
logsContainer,
|
|
56
|
+
containers,
|
|
57
|
+
inputMode,
|
|
58
|
+
inputValue,
|
|
59
|
+
onOptionInputChange,
|
|
60
|
+
searchMode,
|
|
61
|
+
searchText,
|
|
62
|
+
onSearchChange,
|
|
63
|
+
actionsRef,
|
|
64
|
+
}: LogsDialogProps) {
|
|
65
|
+
const lines = logsText ? logsText.split("\n") : [];
|
|
66
|
+
const palette = toneStyles("info");
|
|
67
|
+
const [currentMatchIdx, setCurrentMatchIdx] = useState(0);
|
|
68
|
+
|
|
69
|
+
const matchIndices = useMemo(() => {
|
|
70
|
+
if (!searchText) return [];
|
|
71
|
+
const lower = searchText.toLowerCase();
|
|
72
|
+
return lines.reduce<number[]>((acc, line, i) => {
|
|
73
|
+
if (line.toLowerCase().includes(lower)) acc.push(i);
|
|
74
|
+
return acc;
|
|
75
|
+
}, []);
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
}, [logsText, searchText]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!searchText) return;
|
|
81
|
+
setCurrentMatchIdx(0);
|
|
82
|
+
const firstLine = matchIndices[0];
|
|
83
|
+
if (firstLine !== undefined) {
|
|
84
|
+
scrollRef.current?.scrollTo(firstLine);
|
|
85
|
+
}
|
|
86
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
87
|
+
}, [searchText]);
|
|
88
|
+
|
|
89
|
+
const nextMatch = () => {
|
|
90
|
+
if (matchIndices.length === 0) return;
|
|
91
|
+
const next = (currentMatchIdx + 1) % matchIndices.length;
|
|
92
|
+
setCurrentMatchIdx(next);
|
|
93
|
+
const lineIdx = matchIndices[next];
|
|
94
|
+
if (lineIdx !== undefined) scrollRef.current?.scrollTo(lineIdx);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const prevMatch = () => {
|
|
98
|
+
if (matchIndices.length === 0) return;
|
|
99
|
+
const prev = (currentMatchIdx - 1 + matchIndices.length) % matchIndices.length;
|
|
100
|
+
setCurrentMatchIdx(prev);
|
|
101
|
+
const lineIdx = matchIndices[prev];
|
|
102
|
+
if (lineIdx !== undefined) scrollRef.current?.scrollTo(lineIdx);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (actionsRef) {
|
|
106
|
+
actionsRef.current = { nextMatch, prevMatch };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const warningFg = toneStyles("warning").fg;
|
|
110
|
+
const multiContainer = (containers?.length ?? 0) > 1;
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<box
|
|
114
|
+
style={{
|
|
115
|
+
position: "absolute",
|
|
116
|
+
left: "10%",
|
|
117
|
+
top: "8%",
|
|
118
|
+
width: "80%",
|
|
119
|
+
height: "84%",
|
|
120
|
+
border: true,
|
|
121
|
+
borderColor: palette.border,
|
|
122
|
+
borderStyle: "rounded",
|
|
123
|
+
backgroundColor: OVERLAY_SURFACE,
|
|
124
|
+
paddingLeft: 1,
|
|
125
|
+
paddingRight: 1,
|
|
126
|
+
paddingTop: 1,
|
|
127
|
+
paddingBottom: 1,
|
|
128
|
+
flexDirection: "column",
|
|
129
|
+
}}
|
|
130
|
+
title={`Logs ${target ?? ""}${logsContainer ? ` [${logsContainer}]` : ""}`}
|
|
131
|
+
>
|
|
132
|
+
{/* Options status bar */}
|
|
133
|
+
<box style={{ flexDirection: "row", columnGap: 2, marginBottom: 0 }}>
|
|
134
|
+
<text fg={logsOptions.previous ? warningFg : TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
135
|
+
<span fg={KEY_HINT}>P</span>
|
|
136
|
+
{` prev:${logsOptions.previous ? "on" : "off"}`}
|
|
137
|
+
</text>
|
|
138
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
139
|
+
<span fg={KEY_HINT}>T</span>
|
|
140
|
+
{` tail:${logsOptions.tail}`}
|
|
141
|
+
</text>
|
|
142
|
+
{logsOptions.since ? (
|
|
143
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
144
|
+
<span fg={KEY_HINT}>S</span>
|
|
145
|
+
{` since:${logsOptions.since}`}
|
|
146
|
+
</text>
|
|
147
|
+
) : undefined}
|
|
148
|
+
{multiContainer ? (
|
|
149
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
150
|
+
<span fg={KEY_HINT}>C</span>
|
|
151
|
+
{logsContainer ? ` ctr:${logsContainer}` : " all-ctrs"}
|
|
152
|
+
</text>
|
|
153
|
+
) : undefined}
|
|
154
|
+
</box>
|
|
155
|
+
|
|
156
|
+
{/* Log content */}
|
|
157
|
+
<scrollbox ref={scrollRef} style={{ flexGrow: 1 }} stickyScroll stickyStart="bottom">
|
|
158
|
+
<box style={{ flexDirection: "column", width: "100%" }}>
|
|
159
|
+
{status === "loading" ? (
|
|
160
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
161
|
+
<span fg={KEY_HINT}>{GLYPHS.dotEmpty}</span>
|
|
162
|
+
{" Loading logs..."}
|
|
163
|
+
</text>
|
|
164
|
+
) : lines.length === 0 ? (
|
|
165
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
166
|
+
No logs loaded
|
|
167
|
+
</text>
|
|
168
|
+
) : (
|
|
169
|
+
lines.map((line, index) => {
|
|
170
|
+
if (searchText) {
|
|
171
|
+
const lowerLine = line.toLowerCase();
|
|
172
|
+
const lowerSearch = searchText.toLowerCase();
|
|
173
|
+
const isMatch = lowerLine.includes(lowerSearch);
|
|
174
|
+
if (!isMatch) {
|
|
175
|
+
return (
|
|
176
|
+
<text key={`${index}:${line}`} fg={YAML_COMMENT} attributes={TextAttributes.DIM}>
|
|
177
|
+
{line}
|
|
178
|
+
</text>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const isCurrentMatch = matchIndices[currentMatchIdx] === index;
|
|
182
|
+
const matchStart = lowerLine.indexOf(lowerSearch);
|
|
183
|
+
const before = line.slice(0, matchStart);
|
|
184
|
+
const matched = line.slice(matchStart, matchStart + searchText.length);
|
|
185
|
+
const after = line.slice(matchStart + searchText.length);
|
|
186
|
+
return (
|
|
187
|
+
<text key={`${index}:${line}`} fg={logLineColor(line)}>
|
|
188
|
+
{before}
|
|
189
|
+
<span fg={warningFg} attributes={isCurrentMatch ? TextAttributes.BOLD : TextAttributes.DIM}>
|
|
190
|
+
{matched}
|
|
191
|
+
</span>
|
|
192
|
+
{after}
|
|
193
|
+
</text>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return (
|
|
197
|
+
<text key={`${index}:${line}`} fg={logLineColor(line)}>
|
|
198
|
+
{line}
|
|
199
|
+
</text>
|
|
200
|
+
);
|
|
201
|
+
})
|
|
202
|
+
)}
|
|
203
|
+
</box>
|
|
204
|
+
</scrollbox>
|
|
205
|
+
|
|
206
|
+
{/* Option input row */}
|
|
207
|
+
{inputMode ? (
|
|
208
|
+
<box
|
|
209
|
+
style={{
|
|
210
|
+
border: true,
|
|
211
|
+
borderColor: PANEL_BORDER_ACTIVE,
|
|
212
|
+
borderStyle: "rounded",
|
|
213
|
+
backgroundColor: FILTER_BACKGROUND,
|
|
214
|
+
paddingLeft: 1,
|
|
215
|
+
paddingRight: 1,
|
|
216
|
+
marginTop: 1,
|
|
217
|
+
height: 3,
|
|
218
|
+
justifyContent: "center",
|
|
219
|
+
alignItems: "center",
|
|
220
|
+
flexDirection: "row",
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
224
|
+
{inputMode === "tail" ? "Tail lines:" : "Since (e.g. 1h, 30m):"}
|
|
225
|
+
</text>
|
|
226
|
+
<input
|
|
227
|
+
focused
|
|
228
|
+
value={inputValue ?? ""}
|
|
229
|
+
placeholder={inputMode === "tail" ? "120" : "1h"}
|
|
230
|
+
style={{ flexGrow: 1, marginLeft: 1 }}
|
|
231
|
+
onInput={onOptionInputChange}
|
|
232
|
+
onChange={onOptionInputChange}
|
|
233
|
+
/>
|
|
234
|
+
</box>
|
|
235
|
+
) : undefined}
|
|
236
|
+
|
|
237
|
+
{/* Search input row */}
|
|
238
|
+
{searchMode ? (
|
|
239
|
+
<box
|
|
240
|
+
style={{
|
|
241
|
+
border: true,
|
|
242
|
+
borderColor: PANEL_BORDER_ACTIVE,
|
|
243
|
+
borderStyle: "rounded",
|
|
244
|
+
backgroundColor: FILTER_BACKGROUND,
|
|
245
|
+
paddingLeft: 1,
|
|
246
|
+
paddingRight: 1,
|
|
247
|
+
marginTop: 1,
|
|
248
|
+
height: 3,
|
|
249
|
+
justifyContent: "center",
|
|
250
|
+
alignItems: "center",
|
|
251
|
+
flexDirection: "row",
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
<text fg={KEY_HINT}>/</text>
|
|
255
|
+
<input
|
|
256
|
+
focused
|
|
257
|
+
value={searchText ?? ""}
|
|
258
|
+
placeholder="search..."
|
|
259
|
+
style={{ flexGrow: 1, marginLeft: 1 }}
|
|
260
|
+
onInput={onSearchChange}
|
|
261
|
+
onChange={onSearchChange}
|
|
262
|
+
/>
|
|
263
|
+
{matchIndices.length > 0 ? (
|
|
264
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
265
|
+
{` ${currentMatchIdx + 1}/${matchIndices.length}`}
|
|
266
|
+
</text>
|
|
267
|
+
) : searchText ? (
|
|
268
|
+
<text fg={toneStyles("danger").fg} attributes={TEXT_MUTED}>
|
|
269
|
+
{" no match"}
|
|
270
|
+
</text>
|
|
271
|
+
) : undefined}
|
|
272
|
+
</box>
|
|
273
|
+
) : undefined}
|
|
274
|
+
|
|
275
|
+
{/* Hints */}
|
|
276
|
+
<box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
|
|
277
|
+
{inputMode || searchMode ? (
|
|
278
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
279
|
+
<span fg={KEY_HINT}>{GLYPHS.enter}</span>
|
|
280
|
+
{" confirm"}
|
|
281
|
+
</text>
|
|
282
|
+
) : (
|
|
283
|
+
<>
|
|
284
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
285
|
+
<span fg={KEY_HINT}>{"/"}</span>
|
|
286
|
+
{" search"}
|
|
287
|
+
</text>
|
|
288
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
289
|
+
<span fg={KEY_HINT}>{"↑↓"}</span>
|
|
290
|
+
{" scroll"}
|
|
291
|
+
</text>
|
|
292
|
+
</>
|
|
293
|
+
)}
|
|
294
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
295
|
+
<span fg={KEY_HINT}>{GLYPHS.esc}</span>
|
|
296
|
+
{" close"}
|
|
297
|
+
</text>
|
|
298
|
+
</box>
|
|
299
|
+
</box>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export { logLineColor };
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
FILTER_BACKGROUND,
|
|
5
|
+
GLYPHS,
|
|
6
|
+
KEY_HINT,
|
|
7
|
+
OVERLAY_SURFACE,
|
|
8
|
+
PANEL_BORDER_ACTIVE,
|
|
9
|
+
TEXT_MUTED,
|
|
10
|
+
TEXT_PRIMARY,
|
|
11
|
+
TEXT_SUBTLE,
|
|
12
|
+
toneStyles,
|
|
13
|
+
} from "../../theme";
|
|
14
|
+
import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH } from "./shared";
|
|
15
|
+
import type { ActivePortForward } from "../../../lib/k8s/types";
|
|
16
|
+
|
|
17
|
+
export interface PortForwardEntry {
|
|
18
|
+
remotePort: number;
|
|
19
|
+
suggestedLocalPort: number;
|
|
20
|
+
activeForward?: ActivePortForward | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PortForwardOverlayProps {
|
|
24
|
+
target: string;
|
|
25
|
+
/** All ports declared on the resource, merged with active-forward status */
|
|
26
|
+
entries: PortForwardEntry[];
|
|
27
|
+
/** Index into entries that has keyboard focus */
|
|
28
|
+
selectedIndex: number;
|
|
29
|
+
/** Current value of the local-port input (for the selected inactive entry) */
|
|
30
|
+
localPortValue: string;
|
|
31
|
+
onChange: (value: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildPortForwardEntries(
|
|
35
|
+
portForwards: { localPort: number; remotePort: number }[],
|
|
36
|
+
activeForwards: ActivePortForward[],
|
|
37
|
+
): PortForwardEntry[] {
|
|
38
|
+
const entries: PortForwardEntry[] = portForwards.map((pf) => ({
|
|
39
|
+
remotePort: pf.remotePort,
|
|
40
|
+
suggestedLocalPort: pf.localPort,
|
|
41
|
+
activeForward: activeForwards.find((af) => af.remotePort === pf.remotePort),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
// Append any active forwards whose remotePort is not already in portForwards
|
|
45
|
+
for (const af of activeForwards) {
|
|
46
|
+
if (!entries.some((e) => e.remotePort === af.remotePort)) {
|
|
47
|
+
entries.push({ remotePort: af.remotePort, suggestedLocalPort: af.localPort, activeForward: af });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return entries;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function PortForwardOverlay({
|
|
55
|
+
target,
|
|
56
|
+
entries,
|
|
57
|
+
selectedIndex,
|
|
58
|
+
localPortValue,
|
|
59
|
+
onChange,
|
|
60
|
+
}: PortForwardOverlayProps) {
|
|
61
|
+
const palette = toneStyles("accent");
|
|
62
|
+
const selectedEntry = entries[selectedIndex];
|
|
63
|
+
const selectedIsActive = Boolean(selectedEntry?.activeForward);
|
|
64
|
+
const anyActive = entries.some((e) => e.activeForward);
|
|
65
|
+
const anyInactive = entries.some((e) => !e.activeForward);
|
|
66
|
+
|
|
67
|
+
// height: border(2) + padding(2) + title(1) + list rows(N) + input(4 if inactive selected) + gap(1) + hints(1)
|
|
68
|
+
const inputSection = selectedEntry && !selectedIsActive ? 4 : 0;
|
|
69
|
+
const overlayHeight = Math.min(26, 7 + entries.length + inputSection);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<box
|
|
73
|
+
style={{
|
|
74
|
+
position: "absolute",
|
|
75
|
+
left: DIALOG_LEFT,
|
|
76
|
+
top: DIALOG_TOP,
|
|
77
|
+
width: DIALOG_WIDTH,
|
|
78
|
+
height: overlayHeight,
|
|
79
|
+
border: true,
|
|
80
|
+
borderStyle: "rounded",
|
|
81
|
+
borderColor: palette.border,
|
|
82
|
+
backgroundColor: OVERLAY_SURFACE,
|
|
83
|
+
padding: 1,
|
|
84
|
+
flexDirection: "column",
|
|
85
|
+
}}
|
|
86
|
+
title="Port Forward"
|
|
87
|
+
>
|
|
88
|
+
<text fg={palette.fg} attributes={TextAttributes.BOLD}>
|
|
89
|
+
<span fg={KEY_HINT}>{GLYPHS.forward}</span>
|
|
90
|
+
{` ${target}`}
|
|
91
|
+
</text>
|
|
92
|
+
|
|
93
|
+
<box style={{ flexDirection: "column", marginTop: 1 }}>
|
|
94
|
+
{entries.map((entry, index) => {
|
|
95
|
+
const selected = index === selectedIndex;
|
|
96
|
+
const fwd = entry.activeForward;
|
|
97
|
+
const fwdPalette = fwd
|
|
98
|
+
? toneStyles(fwd.status === "ready" ? "success" : "warning")
|
|
99
|
+
: toneStyles("neutral");
|
|
100
|
+
const statusDot = fwd ? GLYPHS.dot : GLYPHS.dotEmpty;
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<text
|
|
104
|
+
key={entry.remotePort}
|
|
105
|
+
fg={selected ? TEXT_PRIMARY : TEXT_SUBTLE}
|
|
106
|
+
attributes={selected ? undefined : TextAttributes.DIM}
|
|
107
|
+
>
|
|
108
|
+
<span fg={selected ? palette.fg : "transparent"}>{GLYPHS.cursor}</span>
|
|
109
|
+
{" "}
|
|
110
|
+
<span fg={fwdPalette.fg}>{statusDot}</span>
|
|
111
|
+
{fwd
|
|
112
|
+
? ` localhost:${fwd.localPort} → :${entry.remotePort} `
|
|
113
|
+
: ` :${entry.remotePort} `}
|
|
114
|
+
{fwd ? (
|
|
115
|
+
<span fg={fwdPalette.fg} attributes={TextAttributes.DIM}>
|
|
116
|
+
{fwd.status}
|
|
117
|
+
</span>
|
|
118
|
+
) : (
|
|
119
|
+
<span fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
120
|
+
{`→ local :${entry.suggestedLocalPort}`}
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
</text>
|
|
124
|
+
);
|
|
125
|
+
})}
|
|
126
|
+
</box>
|
|
127
|
+
|
|
128
|
+
{selectedEntry && !selectedIsActive && (
|
|
129
|
+
<box
|
|
130
|
+
style={{
|
|
131
|
+
border: true,
|
|
132
|
+
borderColor: PANEL_BORDER_ACTIVE,
|
|
133
|
+
borderStyle: "rounded",
|
|
134
|
+
backgroundColor: FILTER_BACKGROUND,
|
|
135
|
+
paddingLeft: 1,
|
|
136
|
+
paddingRight: 1,
|
|
137
|
+
marginTop: 1,
|
|
138
|
+
height: 3,
|
|
139
|
+
justifyContent: "center",
|
|
140
|
+
alignItems: "center",
|
|
141
|
+
flexDirection: "row",
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
145
|
+
Local:
|
|
146
|
+
</text>
|
|
147
|
+
<input
|
|
148
|
+
focused
|
|
149
|
+
value={localPortValue}
|
|
150
|
+
placeholder={String(selectedEntry.suggestedLocalPort)}
|
|
151
|
+
style={{ flexGrow: 1, marginLeft: 1 }}
|
|
152
|
+
onInput={onChange}
|
|
153
|
+
onChange={onChange}
|
|
154
|
+
/>
|
|
155
|
+
</box>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
<box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
|
|
159
|
+
{anyInactive && (
|
|
160
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
161
|
+
<span fg={KEY_HINT}>{GLYPHS.enter}</span>
|
|
162
|
+
{" start"}
|
|
163
|
+
</text>
|
|
164
|
+
)}
|
|
165
|
+
{anyActive && (
|
|
166
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
167
|
+
<span fg={KEY_HINT}>{"X"}</span>
|
|
168
|
+
{" stop"}
|
|
169
|
+
</text>
|
|
170
|
+
)}
|
|
171
|
+
{entries.length > 1 && (
|
|
172
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
173
|
+
<span fg={KEY_HINT}>{"↑↓"}</span>
|
|
174
|
+
{" navigate"}
|
|
175
|
+
</text>
|
|
176
|
+
)}
|
|
177
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
178
|
+
<span fg={KEY_HINT}>{GLYPHS.esc}</span>
|
|
179
|
+
{" close"}
|
|
180
|
+
</text>
|
|
181
|
+
</box>
|
|
182
|
+
</box>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
FILTER_BACKGROUND,
|
|
5
|
+
GLYPHS,
|
|
6
|
+
KEY_HINT,
|
|
7
|
+
OVERLAY_SURFACE,
|
|
8
|
+
PANEL_BORDER_ACTIVE,
|
|
9
|
+
TEXT_MUTED,
|
|
10
|
+
TEXT_PRIMARY,
|
|
11
|
+
TEXT_SUBTLE,
|
|
12
|
+
toneStyles,
|
|
13
|
+
} from "../../theme";
|
|
14
|
+
import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH } from "./shared";
|
|
15
|
+
|
|
16
|
+
export interface ScaleDialogProps {
|
|
17
|
+
target: string;
|
|
18
|
+
currentReplicas?: number | undefined;
|
|
19
|
+
replicasValue: string;
|
|
20
|
+
onChange: (value: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ScaleDialog({ target, currentReplicas, replicasValue, onChange }: ScaleDialogProps) {
|
|
24
|
+
const palette = toneStyles("accent");
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<box
|
|
28
|
+
style={{
|
|
29
|
+
position: "absolute",
|
|
30
|
+
left: DIALOG_LEFT,
|
|
31
|
+
top: DIALOG_TOP,
|
|
32
|
+
width: DIALOG_WIDTH,
|
|
33
|
+
height: 12,
|
|
34
|
+
border: true,
|
|
35
|
+
borderStyle: "rounded",
|
|
36
|
+
borderColor: palette.border,
|
|
37
|
+
backgroundColor: OVERLAY_SURFACE,
|
|
38
|
+
padding: 1,
|
|
39
|
+
flexDirection: "column",
|
|
40
|
+
}}
|
|
41
|
+
title="Scale Replicas"
|
|
42
|
+
>
|
|
43
|
+
<text fg={palette.fg} attributes={TextAttributes.BOLD}>
|
|
44
|
+
{target}
|
|
45
|
+
</text>
|
|
46
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
47
|
+
{currentReplicas !== undefined ? (
|
|
48
|
+
<>
|
|
49
|
+
{"Current "}
|
|
50
|
+
<span fg={TEXT_PRIMARY} attributes={TextAttributes.BOLD}>{currentReplicas}</span>
|
|
51
|
+
{" replicas"}
|
|
52
|
+
</>
|
|
53
|
+
) : (
|
|
54
|
+
"Replicas unknown"
|
|
55
|
+
)}
|
|
56
|
+
</text>
|
|
57
|
+
<box
|
|
58
|
+
style={{
|
|
59
|
+
border: true,
|
|
60
|
+
borderColor: PANEL_BORDER_ACTIVE,
|
|
61
|
+
borderStyle: "rounded",
|
|
62
|
+
backgroundColor: FILTER_BACKGROUND,
|
|
63
|
+
paddingLeft: 1,
|
|
64
|
+
paddingRight: 1,
|
|
65
|
+
marginTop: 1,
|
|
66
|
+
height: 3,
|
|
67
|
+
justifyContent: "center",
|
|
68
|
+
alignItems: "center",
|
|
69
|
+
flexDirection: "row",
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
73
|
+
Target:
|
|
74
|
+
</text>
|
|
75
|
+
<input
|
|
76
|
+
focused
|
|
77
|
+
value={replicasValue}
|
|
78
|
+
placeholder={currentReplicas !== undefined ? String(currentReplicas) : "0"}
|
|
79
|
+
style={{ flexGrow: 1, marginLeft: 1 }}
|
|
80
|
+
onInput={onChange}
|
|
81
|
+
onChange={onChange}
|
|
82
|
+
/>
|
|
83
|
+
</box>
|
|
84
|
+
<box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
|
|
85
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
86
|
+
<span fg={KEY_HINT}>{GLYPHS.enter}</span>
|
|
87
|
+
{" scale"}
|
|
88
|
+
</text>
|
|
89
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
90
|
+
<span fg={KEY_HINT}>{GLYPHS.esc}</span>
|
|
91
|
+
{" cancel"}
|
|
92
|
+
</text>
|
|
93
|
+
</box>
|
|
94
|
+
</box>
|
|
95
|
+
);
|
|
96
|
+
}
|