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.
Files changed (35) hide show
  1. package/README.md +187 -36
  2. package/bin/openk8s.js +2 -0
  3. package/package.json +52 -6
  4. package/src/app/app-state.ts +461 -0
  5. package/src/app/app.tsx +708 -0
  6. package/src/app/components/inspector.tsx +449 -0
  7. package/src/app/components/kind-rows.tsx +66 -0
  8. package/src/app/components/notification-tray.tsx +59 -0
  9. package/src/app/components/overlays/delete-confirm-overlay.tsx +79 -0
  10. package/src/app/components/overlays/helm-rollback-overlay.tsx +86 -0
  11. package/src/app/components/overlays/index.ts +12 -0
  12. package/src/app/components/overlays/logs-dialog.tsx +303 -0
  13. package/src/app/components/overlays/port-forward-overlay.tsx +184 -0
  14. package/src/app/components/overlays/scale-dialog.tsx +96 -0
  15. package/src/app/components/overlays/select-overlay.tsx +68 -0
  16. package/src/app/components/overlays/shared.tsx +18 -0
  17. package/src/app/components/port-forwards-tray.tsx +57 -0
  18. package/src/app/components/resource-rows.tsx +120 -0
  19. package/src/app/hooks/use-app-keyboard.ts +723 -0
  20. package/src/app/hooks/use-app-side-effects.ts +39 -0
  21. package/src/app/hooks/use-clipboard.ts +54 -0
  22. package/src/app/hooks/use-data-fetching.ts +366 -0
  23. package/src/app/hooks/use-log-stream.ts +113 -0
  24. package/src/app/hooks/use-port-forward.ts +149 -0
  25. package/src/app/persistence.ts +44 -0
  26. package/src/app/theme.ts +95 -0
  27. package/src/app/use-polling-tick.ts +27 -0
  28. package/src/app/utils.ts +274 -0
  29. package/src/index.tsx +8 -0
  30. package/src/lib/k8s/k8s-format.ts +42 -0
  31. package/src/lib/k8s/resource-detail-builder.ts +545 -0
  32. package/src/lib/k8s/resource-parser.ts +308 -0
  33. package/src/lib/k8s/types.ts +164 -0
  34. package/src/lib/kubectl/kubectl-service.ts +1116 -0
  35. 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
+ }