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,449 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
GLYPHS,
|
|
5
|
+
KEY_HINT,
|
|
6
|
+
PANEL_BORDER,
|
|
7
|
+
PANEL_BORDER_ACTIVE,
|
|
8
|
+
ROW_SELECTED,
|
|
9
|
+
TEXT_PRIMARY,
|
|
10
|
+
TEXT_SUBTLE,
|
|
11
|
+
YAML_COMMENT,
|
|
12
|
+
YAML_KEY,
|
|
13
|
+
YAML_VALUE,
|
|
14
|
+
toneStyles,
|
|
15
|
+
} from "../theme";
|
|
16
|
+
import { statusTone } from "../utils";
|
|
17
|
+
import type { EventItem, InspectorTab, PaneId, ResourceDetail, ResourceDetailLine, ResourceDetailSection } from "../../lib/k8s/types";
|
|
18
|
+
|
|
19
|
+
export const INSPECTOR_TABS: Array<{ id: InspectorTab; label: string; hotkey: string }> = [
|
|
20
|
+
{ id: "summary", label: "Summary", hotkey: "s" },
|
|
21
|
+
{ id: "yaml", label: "YAML", hotkey: "y" },
|
|
22
|
+
{ id: "events", label: "Events", hotkey: "v" },
|
|
23
|
+
{ id: "describe", label: "Describe", hotkey: "i" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
interface YamlToken {
|
|
27
|
+
text: string;
|
|
28
|
+
fg: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tokenizeDescribeLine(line: string): YamlToken[] {
|
|
32
|
+
if (!line.trim()) {
|
|
33
|
+
return [{ text: line || " ", fg: TEXT_SUBTLE }];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Section header lines (e.g. "Name:", "Labels:", or "Events:")
|
|
37
|
+
if (/^[A-Z][A-Za-z ]+:/.test(line.trimStart()) && !line.startsWith(" ")) {
|
|
38
|
+
const colonIdx = line.indexOf(":");
|
|
39
|
+
const key = line.slice(0, colonIdx);
|
|
40
|
+
const rest = line.slice(colonIdx + 1);
|
|
41
|
+
return [
|
|
42
|
+
{ text: key, fg: YAML_KEY },
|
|
43
|
+
{ text: ":", fg: TEXT_SUBTLE },
|
|
44
|
+
{ text: rest, fg: YAML_VALUE },
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Indented key: value lines
|
|
49
|
+
const colonSpaceIdx = line.indexOf(": ");
|
|
50
|
+
if (colonSpaceIdx > 0) {
|
|
51
|
+
const key = line.slice(0, colonSpaceIdx);
|
|
52
|
+
const value = line.slice(colonSpaceIdx + 1);
|
|
53
|
+
return [
|
|
54
|
+
{ text: key, fg: TEXT_SUBTLE },
|
|
55
|
+
{ text: value, fg: YAML_VALUE },
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const colonIdx = line.indexOf(":");
|
|
60
|
+
if (colonIdx > 0 && colonIdx < line.length - 1) {
|
|
61
|
+
const key = line.slice(0, colonIdx);
|
|
62
|
+
const value = line.slice(colonIdx + 1);
|
|
63
|
+
return [
|
|
64
|
+
{ text: key, fg: TEXT_SUBTLE },
|
|
65
|
+
{ text: ":", fg: TEXT_SUBTLE },
|
|
66
|
+
{ text: value, fg: YAML_VALUE },
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [{ text: line, fg: TEXT_SUBTLE }];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function tokenizeYamlLine(line: string): YamlToken[] { if (!line.trim()) {
|
|
74
|
+
return [{ text: line || " ", fg: TEXT_SUBTLE }];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (/^\s*#/.test(line)) {
|
|
78
|
+
return [{ text: line, fg: YAML_COMMENT }];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const indentLen = line.length - line.trimStart().length;
|
|
82
|
+
const indent = line.slice(0, indentLen);
|
|
83
|
+
let rest = line.trimStart();
|
|
84
|
+
|
|
85
|
+
// Strip list marker
|
|
86
|
+
let listMarker = "";
|
|
87
|
+
if (rest.startsWith("- ")) {
|
|
88
|
+
listMarker = "- ";
|
|
89
|
+
rest = rest.slice(2);
|
|
90
|
+
} else if (rest === "-") {
|
|
91
|
+
return [{ text: line, fg: TEXT_SUBTLE }];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// "key: value" or "key:"
|
|
95
|
+
const colonSpaceIdx = rest.indexOf(": ");
|
|
96
|
+
const isKeyOnly = rest === rest.trimEnd() && rest.endsWith(":");
|
|
97
|
+
|
|
98
|
+
if (colonSpaceIdx !== -1) {
|
|
99
|
+
const key = rest.slice(0, colonSpaceIdx);
|
|
100
|
+
const value = rest.slice(colonSpaceIdx + 2);
|
|
101
|
+
return [
|
|
102
|
+
{ text: indent + listMarker, fg: TEXT_SUBTLE },
|
|
103
|
+
{ text: key, fg: YAML_KEY },
|
|
104
|
+
{ text: ": ", fg: TEXT_SUBTLE },
|
|
105
|
+
{ text: value, fg: YAML_VALUE },
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isKeyOnly) {
|
|
110
|
+
const key = rest.slice(0, -1);
|
|
111
|
+
return [
|
|
112
|
+
{ text: indent + listMarker, fg: TEXT_SUBTLE },
|
|
113
|
+
{ text: key, fg: YAML_KEY },
|
|
114
|
+
{ text: ":", fg: TEXT_SUBTLE },
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return [{ text: line, fg: TEXT_SUBTLE }];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface InspectorTabsProps {
|
|
122
|
+
activeTab: InspectorTab;
|
|
123
|
+
activePane: PaneId;
|
|
124
|
+
onSelect: (tab: InspectorTab) => void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function InspectorTabs({ activeTab, activePane, onSelect }: InspectorTabsProps) {
|
|
128
|
+
return (
|
|
129
|
+
<box style={{ flexDirection: "row", marginBottom: 0 }}>
|
|
130
|
+
{INSPECTOR_TABS.map((tab) => {
|
|
131
|
+
const selected = tab.id === activeTab;
|
|
132
|
+
const isActive = selected && activePane === "inspector";
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<box key={tab.id} onMouseDown={() => onSelect(tab.id)} style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
136
|
+
<box
|
|
137
|
+
style={{
|
|
138
|
+
height: 2,
|
|
139
|
+
backgroundColor: selected ? ROW_SELECTED : undefined,
|
|
140
|
+
paddingLeft: 1,
|
|
141
|
+
paddingRight: 1,
|
|
142
|
+
justifyContent: "center",
|
|
143
|
+
alignItems: "center",
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<text
|
|
147
|
+
fg={selected ? TEXT_PRIMARY : TEXT_SUBTLE}
|
|
148
|
+
attributes={isActive ? TextAttributes.BOLD : TextAttributes.DIM}
|
|
149
|
+
>
|
|
150
|
+
<span fg={selected ? KEY_HINT : TEXT_SUBTLE}>
|
|
151
|
+
{selected ? GLYPHS.dot : GLYPHS.dotEmpty}
|
|
152
|
+
</span>
|
|
153
|
+
{` ${tab.label}`}
|
|
154
|
+
</text>
|
|
155
|
+
</box>
|
|
156
|
+
<box
|
|
157
|
+
style={{
|
|
158
|
+
height: 1,
|
|
159
|
+
backgroundColor: selected ? PANEL_BORDER_ACTIVE : PANEL_BORDER,
|
|
160
|
+
}}
|
|
161
|
+
/>
|
|
162
|
+
</box>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</box>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface InspectorBodyProps {
|
|
170
|
+
activeTab: InspectorTab;
|
|
171
|
+
detail?: ResourceDetail | undefined;
|
|
172
|
+
detailStatus: string;
|
|
173
|
+
events: EventItem[];
|
|
174
|
+
eventsStatus: string;
|
|
175
|
+
fallbackLines: string[];
|
|
176
|
+
active: boolean;
|
|
177
|
+
onActivate: () => void;
|
|
178
|
+
revealedDetailLineIds: string[];
|
|
179
|
+
revealedSecretValues: Record<string, string>;
|
|
180
|
+
onToggleReveal: (id: string) => void;
|
|
181
|
+
onCopyText: (text: string, label?: string) => void;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function InspectorBody({
|
|
185
|
+
activeTab,
|
|
186
|
+
detail,
|
|
187
|
+
detailStatus,
|
|
188
|
+
events,
|
|
189
|
+
eventsStatus,
|
|
190
|
+
fallbackLines,
|
|
191
|
+
active,
|
|
192
|
+
onActivate,
|
|
193
|
+
revealedDetailLineIds,
|
|
194
|
+
revealedSecretValues,
|
|
195
|
+
onToggleReveal,
|
|
196
|
+
onCopyText,
|
|
197
|
+
}: InspectorBodyProps) {
|
|
198
|
+
// YAML tab — scrollX enables horizontal scrolling (no maxWidth constraint on content).
|
|
199
|
+
// The 1-row gap is a non-scrollable spacer inside the wrapper, before the scrollbox.
|
|
200
|
+
if (activeTab === "yaml") {
|
|
201
|
+
return (
|
|
202
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
203
|
+
<box style={{ height: 1 }} />
|
|
204
|
+
<scrollbox
|
|
205
|
+
focused={active}
|
|
206
|
+
scrollX
|
|
207
|
+
horizontalScrollbarOptions={{ showArrows: false }}
|
|
208
|
+
onMouseDown={() => {
|
|
209
|
+
onActivate();
|
|
210
|
+
if (detail?.yaml) onCopyText(detail.yaml, "YAML");
|
|
211
|
+
}}
|
|
212
|
+
flexGrow={1}
|
|
213
|
+
>
|
|
214
|
+
<box style={{ flexDirection: "column", paddingRight: 1 }}>
|
|
215
|
+
{detail ? (
|
|
216
|
+
detail.yaml.split("\n").map((line, index) => {
|
|
217
|
+
const tokens = tokenizeYamlLine(line);
|
|
218
|
+
return (
|
|
219
|
+
<box key={`${index}:${line}`} style={{ flexDirection: "row" }}>
|
|
220
|
+
{tokens.map((token, tokenIndex) => (
|
|
221
|
+
<text key={tokenIndex} fg={token.fg} wrapMode="none">
|
|
222
|
+
{token.text}
|
|
223
|
+
</text>
|
|
224
|
+
))}
|
|
225
|
+
</box>
|
|
226
|
+
);
|
|
227
|
+
})
|
|
228
|
+
) : detailStatus === "loading" ? (
|
|
229
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
230
|
+
Loading YAML...
|
|
231
|
+
</text>
|
|
232
|
+
) : (
|
|
233
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
234
|
+
No YAML available
|
|
235
|
+
</text>
|
|
236
|
+
)}
|
|
237
|
+
</box>
|
|
238
|
+
</scrollbox>
|
|
239
|
+
</box>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Describe tab — raw kubectl describe output with light colorization
|
|
244
|
+
if (activeTab === "describe") {
|
|
245
|
+
return (
|
|
246
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
247
|
+
<box style={{ height: 1 }} />
|
|
248
|
+
<scrollbox
|
|
249
|
+
focused={active}
|
|
250
|
+
scrollX
|
|
251
|
+
horizontalScrollbarOptions={{ showArrows: false }}
|
|
252
|
+
onMouseDown={onActivate}
|
|
253
|
+
flexGrow={1}
|
|
254
|
+
>
|
|
255
|
+
<box style={{ flexDirection: "column", paddingRight: 1 }}>
|
|
256
|
+
{detail?.describe ? (
|
|
257
|
+
detail.describe.split("\n").map((line, index) => {
|
|
258
|
+
const tokens = tokenizeDescribeLine(line);
|
|
259
|
+
return (
|
|
260
|
+
<box key={`${index}:${line}`} style={{ flexDirection: "row" }}>
|
|
261
|
+
{tokens.map((token, tokenIndex) => (
|
|
262
|
+
<text key={tokenIndex} fg={token.fg} wrapMode="none">
|
|
263
|
+
{token.text}
|
|
264
|
+
</text>
|
|
265
|
+
))}
|
|
266
|
+
</box>
|
|
267
|
+
);
|
|
268
|
+
})
|
|
269
|
+
) : detailStatus === "loading" ? (
|
|
270
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
271
|
+
Loading describe output...
|
|
272
|
+
</text>
|
|
273
|
+
) : (
|
|
274
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
275
|
+
No describe output available
|
|
276
|
+
</text>
|
|
277
|
+
)}
|
|
278
|
+
</box>
|
|
279
|
+
</scrollbox>
|
|
280
|
+
</box>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Summary / Events tabs
|
|
285
|
+
return (
|
|
286
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
287
|
+
<box style={{ height: 1 }} />
|
|
288
|
+
<scrollbox focused={active} onMouseDown={onActivate} flexGrow={1}>
|
|
289
|
+
<box style={{ flexDirection: "column", width: "100%", paddingRight: 1 }}>
|
|
290
|
+
{activeTab === "events" ? (
|
|
291
|
+
<EventList events={events} status={eventsStatus} />
|
|
292
|
+
) : detail ? (
|
|
293
|
+
<DetailSectionsInternal
|
|
294
|
+
sections={detail.summarySections}
|
|
295
|
+
revealedIds={revealedDetailLineIds}
|
|
296
|
+
revealedSecretValues={revealedSecretValues}
|
|
297
|
+
onToggleReveal={onToggleReveal}
|
|
298
|
+
onCopyText={onCopyText}
|
|
299
|
+
/>
|
|
300
|
+
) : detailStatus === "loading" ? (
|
|
301
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
302
|
+
Loading resource detail...
|
|
303
|
+
</text>
|
|
304
|
+
) : (
|
|
305
|
+
fallbackLines.map((line) => (
|
|
306
|
+
<text key={line} fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
307
|
+
{line}
|
|
308
|
+
</text>
|
|
309
|
+
))
|
|
310
|
+
)}
|
|
311
|
+
</box>
|
|
312
|
+
</scrollbox>
|
|
313
|
+
</box>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
interface EventListProps {
|
|
318
|
+
events: EventItem[];
|
|
319
|
+
status: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function EventList({ events, status }: EventListProps) {
|
|
323
|
+
if (status === "loading") {
|
|
324
|
+
return (
|
|
325
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
326
|
+
Loading events...
|
|
327
|
+
</text>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (events.length === 0) {
|
|
332
|
+
return (
|
|
333
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
334
|
+
No related events
|
|
335
|
+
</text>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<box style={{ flexDirection: "column", width: "100%" }}>
|
|
341
|
+
{events.map((event, index) => {
|
|
342
|
+
const isWarning = event.type.toLowerCase() === "warning";
|
|
343
|
+
const palette = toneStyles(isWarning ? "warning" : "info");
|
|
344
|
+
const typeGlyph = isWarning ? GLYPHS.warn : GLYPHS.dot;
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<box key={`${event.reason}:${event.age}:${index}`} style={{ flexDirection: "column", marginBottom: 1 }}>
|
|
348
|
+
<text fg={palette.fg}>
|
|
349
|
+
<span fg={palette.border}>{typeGlyph}</span>
|
|
350
|
+
{` ${event.type} `}
|
|
351
|
+
<span fg={TEXT_SUBTLE}>{GLYPHS.sep}</span>
|
|
352
|
+
{` ${event.reason} ${event.age}`}
|
|
353
|
+
</text>
|
|
354
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>{` ${event.source} ${event.message}`}</text>
|
|
355
|
+
</box>
|
|
356
|
+
);
|
|
357
|
+
})}
|
|
358
|
+
</box>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
interface DetailSectionsInternalProps {
|
|
363
|
+
sections: ResourceDetailSection[];
|
|
364
|
+
revealedIds: string[];
|
|
365
|
+
revealedSecretValues: Record<string, string>;
|
|
366
|
+
onToggleReveal: (id: string) => void;
|
|
367
|
+
onCopyText: (text: string, label?: string) => void;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function DetailSectionsInternal({ sections, revealedIds, revealedSecretValues, onToggleReveal, onCopyText }: DetailSectionsInternalProps) {
|
|
371
|
+
return (
|
|
372
|
+
<box style={{ flexDirection: "column", width: "100%" }}>
|
|
373
|
+
{sections.map((section) => (
|
|
374
|
+
<box key={section.title} style={{ flexDirection: "column", marginBottom: 1 }}>
|
|
375
|
+
{/* Section header: ─ Title */}
|
|
376
|
+
<text fg={KEY_HINT}>
|
|
377
|
+
{"─ "}
|
|
378
|
+
<span fg={TEXT_PRIMARY} attributes={TextAttributes.BOLD}>{section.title}</span>
|
|
379
|
+
</text>
|
|
380
|
+
{section.lines.map((line, index) => {
|
|
381
|
+
// Narrow to structured line early so the rest is clean
|
|
382
|
+
const structuredLine: ResourceDetailLine | undefined =
|
|
383
|
+
typeof line === "string" ? undefined : line;
|
|
384
|
+
const lineId = structuredLine?.id ?? line as string;
|
|
385
|
+
const isRevealable = !!(structuredLine?.revealable && structuredLine.revealedText);
|
|
386
|
+
const isRevealed = isRevealable && revealedIds.includes(structuredLine!.id);
|
|
387
|
+
|
|
388
|
+
// Prefer the live-fetched secret value; fall back to the static revealedText placeholder
|
|
389
|
+
const fetchedValue = structuredLine ? revealedSecretValues[structuredLine.id] : undefined;
|
|
390
|
+
|
|
391
|
+
// raw line.text — no "[click to reveal]" suffix
|
|
392
|
+
const rawText = structuredLine?.text ?? (line as string);
|
|
393
|
+
const displayText = isRevealable && isRevealed
|
|
394
|
+
? (fetchedValue ?? structuredLine!.revealedText ?? rawText)
|
|
395
|
+
: rawText;
|
|
396
|
+
// What gets copied: fetched value > revealedText > rawText
|
|
397
|
+
const copyValue = fetchedValue ?? structuredLine?.revealedText ?? rawText;
|
|
398
|
+
// Show [copy] on all structured lines (env vars) regardless of revealable status
|
|
399
|
+
const hasCopyButton = structuredLine !== undefined;
|
|
400
|
+
|
|
401
|
+
// Short label for the "copied" toast: extract var name before "=" or first word
|
|
402
|
+
const copyLabel: string | undefined = structuredLine
|
|
403
|
+
? (() => {
|
|
404
|
+
const eqIdx = rawText.indexOf("=");
|
|
405
|
+
if (eqIdx > 0) return rawText.slice(0, eqIdx);
|
|
406
|
+
const spaceIdx = rawText.indexOf(" ");
|
|
407
|
+
return spaceIdx > 0 ? rawText.slice(0, spaceIdx) : rawText;
|
|
408
|
+
})()
|
|
409
|
+
: undefined;
|
|
410
|
+
|
|
411
|
+
return (
|
|
412
|
+
<box
|
|
413
|
+
key={`${section.title}:${index}:${lineId}`}
|
|
414
|
+
style={{ flexDirection: "row", width: "100%" }}
|
|
415
|
+
>
|
|
416
|
+
<box
|
|
417
|
+
onMouseDown={isRevealable ? () => onToggleReveal(structuredLine!.id) : undefined}
|
|
418
|
+
style={{ flexGrow: 1 }}
|
|
419
|
+
>
|
|
420
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
421
|
+
{" "}
|
|
422
|
+
{displayText}
|
|
423
|
+
</text>
|
|
424
|
+
</box>
|
|
425
|
+
{isRevealable && (
|
|
426
|
+
<box
|
|
427
|
+
onMouseDown={() => onToggleReveal(structuredLine!.id)}
|
|
428
|
+
style={{ paddingLeft: 1 }}
|
|
429
|
+
>
|
|
430
|
+
<text fg={KEY_HINT} attributes={TextAttributes.DIM}>
|
|
431
|
+
{isRevealed ? "[hide]" : "[reveal]"}
|
|
432
|
+
</text>
|
|
433
|
+
</box>
|
|
434
|
+
)}
|
|
435
|
+
{hasCopyButton && (
|
|
436
|
+
<box onMouseDown={() => onCopyText(copyValue, copyLabel)} style={{ paddingLeft: 1 }}>
|
|
437
|
+
<text fg={KEY_HINT} attributes={TextAttributes.DIM}>
|
|
438
|
+
{"[copy]"}
|
|
439
|
+
</text>
|
|
440
|
+
</box>
|
|
441
|
+
)}
|
|
442
|
+
</box>
|
|
443
|
+
);
|
|
444
|
+
})}
|
|
445
|
+
</box>
|
|
446
|
+
))}
|
|
447
|
+
</box>
|
|
448
|
+
);
|
|
449
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
|
|
4
|
+
import { GLYPHS, KEY_HINT, ROW_SELECTED_ALT, TEXT_PRIMARY, TEXT_SUBTLE } from "../theme";
|
|
5
|
+
import { kindDescription } from "../utils";
|
|
6
|
+
import type { ResourceKind } from "../../lib/k8s/types";
|
|
7
|
+
|
|
8
|
+
export interface KindRowsProps {
|
|
9
|
+
resourceKinds: ResourceKind[];
|
|
10
|
+
selectedKind: string;
|
|
11
|
+
onSelect: (kind: string) => void;
|
|
12
|
+
onActivate: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function KindRows({ resourceKinds, selectedKind, onSelect, onActivate }: KindRowsProps) {
|
|
16
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
scrollRef.current?.scrollChildIntoView(`kind:${selectedKind}`);
|
|
20
|
+
}, [selectedKind]);
|
|
21
|
+
|
|
22
|
+
if (resourceKinds.length === 0) {
|
|
23
|
+
return (
|
|
24
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
25
|
+
No discovered resources
|
|
26
|
+
</text>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<scrollbox ref={scrollRef} onMouseDown={onActivate} style={{ flexGrow: 1 }}>
|
|
32
|
+
<box style={{ flexDirection: "column", width: "100%" }}>
|
|
33
|
+
{resourceKinds.map((kind) => {
|
|
34
|
+
const selected = kind.name === selectedKind;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<box
|
|
38
|
+
id={`kind:${kind.name}`}
|
|
39
|
+
key={kind.name}
|
|
40
|
+
onMouseDown={() => {
|
|
41
|
+
onActivate();
|
|
42
|
+
onSelect(kind.name);
|
|
43
|
+
}}
|
|
44
|
+
style={{
|
|
45
|
+
width: "100%",
|
|
46
|
+
flexDirection: "column",
|
|
47
|
+
paddingLeft: 1,
|
|
48
|
+
paddingRight: 1,
|
|
49
|
+
backgroundColor: selected ? ROW_SELECTED_ALT : undefined,
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<text fg={selected ? TEXT_PRIMARY : TEXT_SUBTLE} attributes={selected ? TextAttributes.BOLD : TextAttributes.DIM}>
|
|
53
|
+
<span fg={selected ? KEY_HINT : "transparent"}>{GLYPHS.cursor}</span>
|
|
54
|
+
{` ${kind.name}`}
|
|
55
|
+
</text>
|
|
56
|
+
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
57
|
+
{" "}
|
|
58
|
+
{kindDescription(kind)}
|
|
59
|
+
</text>
|
|
60
|
+
</box>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</box>
|
|
64
|
+
</scrollbox>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { GLYPHS, OVERLAY_SURFACE, TEXT_SUBTLE, toneStyles } from "../theme";
|
|
2
|
+
import type { Notification } from "../../lib/k8s/types";
|
|
3
|
+
|
|
4
|
+
export interface NotificationTrayProps {
|
|
5
|
+
notifications: Notification[];
|
|
6
|
+
onDismiss: (id: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function NotificationTray({ notifications, onDismiss }: NotificationTrayProps) {
|
|
10
|
+
if (notifications.length === 0) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Show at most 3, most recent last (rendered top-to-bottom)
|
|
15
|
+
const visible = notifications.slice(-3);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<box
|
|
19
|
+
style={{
|
|
20
|
+
position: "absolute",
|
|
21
|
+
left: "55%",
|
|
22
|
+
top: 3,
|
|
23
|
+
width: "44%",
|
|
24
|
+
flexDirection: "column",
|
|
25
|
+
rowGap: 1,
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
{visible.map((notification) => {
|
|
29
|
+
const palette = toneStyles(notification.tone);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<box
|
|
33
|
+
key={notification.id}
|
|
34
|
+
style={{
|
|
35
|
+
border: true,
|
|
36
|
+
borderColor: palette.border,
|
|
37
|
+
borderStyle: "rounded",
|
|
38
|
+
backgroundColor: OVERLAY_SURFACE,
|
|
39
|
+
paddingLeft: 1,
|
|
40
|
+
paddingRight: 1,
|
|
41
|
+
paddingTop: 0,
|
|
42
|
+
paddingBottom: 0,
|
|
43
|
+
flexDirection: "row",
|
|
44
|
+
justifyContent: "space-between",
|
|
45
|
+
alignItems: "center",
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
<text fg={palette.fg} wrapMode="word" style={{ flexShrink: 1, flexGrow: 1 }}>
|
|
49
|
+
{notification.message}
|
|
50
|
+
</text>
|
|
51
|
+
<text fg={TEXT_SUBTLE} onMouseDown={() => onDismiss(notification.id)}>
|
|
52
|
+
{` ${GLYPHS.stop}`}
|
|
53
|
+
</text>
|
|
54
|
+
</box>
|
|
55
|
+
);
|
|
56
|
+
})}
|
|
57
|
+
</box>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DANGER,
|
|
5
|
+
GLYPHS,
|
|
6
|
+
KEY_HINT,
|
|
7
|
+
OVERLAY_SURFACE,
|
|
8
|
+
TEXT_MUTED,
|
|
9
|
+
TEXT_SUBTLE,
|
|
10
|
+
toneStyles,
|
|
11
|
+
} from "../../theme";
|
|
12
|
+
import { DIALOG_LEFT, DIALOG_TOP, DIALOG_WIDTH } from "./shared";
|
|
13
|
+
import { statusLine } from "../../utils";
|
|
14
|
+
import type { ResourceRef } from "../../../lib/k8s/types";
|
|
15
|
+
|
|
16
|
+
export interface DeleteConfirmOverlayProps {
|
|
17
|
+
targets: ResourceRef[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function DeleteConfirmOverlay({ targets }: DeleteConfirmOverlayProps) {
|
|
21
|
+
const palette = toneStyles("danger");
|
|
22
|
+
const title =
|
|
23
|
+
targets.length <= 1 ? `Delete ${statusLine({ ref: targets[0] })}?` : `Delete ${targets.length} selected resources?`;
|
|
24
|
+
const overlayHeight = Math.min(22, Math.max(9, targets.length + 8));
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<box
|
|
28
|
+
style={{
|
|
29
|
+
position: "absolute",
|
|
30
|
+
left: DIALOG_LEFT,
|
|
31
|
+
top: DIALOG_TOP,
|
|
32
|
+
width: DIALOG_WIDTH,
|
|
33
|
+
height: overlayHeight,
|
|
34
|
+
border: true,
|
|
35
|
+
borderStyle: "rounded",
|
|
36
|
+
borderColor: palette.border,
|
|
37
|
+
backgroundColor: OVERLAY_SURFACE,
|
|
38
|
+
padding: 1,
|
|
39
|
+
flexDirection: "column",
|
|
40
|
+
}}
|
|
41
|
+
title="Confirm Delete"
|
|
42
|
+
>
|
|
43
|
+
<text fg={palette.fg} attributes={TextAttributes.BOLD}>
|
|
44
|
+
{title}
|
|
45
|
+
</text>
|
|
46
|
+
{targets.length <= 1 ? (
|
|
47
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
48
|
+
Runs kubectl delete on the current selection.
|
|
49
|
+
</text>
|
|
50
|
+
) : (
|
|
51
|
+
<scrollbox style={{ flexGrow: 1, marginTop: 1 }}>
|
|
52
|
+
<box style={{ flexDirection: "column", width: "100%" }}>
|
|
53
|
+
{targets.map((target) => (
|
|
54
|
+
<text
|
|
55
|
+
key={`${target.kind}:${target.namespace ?? "cluster"}:${target.name}`}
|
|
56
|
+
fg={TEXT_SUBTLE}
|
|
57
|
+
attributes={TEXT_MUTED}
|
|
58
|
+
>
|
|
59
|
+
<span fg={DANGER}>{GLYPHS.cross}</span>
|
|
60
|
+
{" "}
|
|
61
|
+
{statusLine({ ref: target })}
|
|
62
|
+
</text>
|
|
63
|
+
))}
|
|
64
|
+
</box>
|
|
65
|
+
</scrollbox>
|
|
66
|
+
)}
|
|
67
|
+
<box style={{ flexDirection: "row", columnGap: 2, marginTop: 1, justifyContent: "flex-end" }}>
|
|
68
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
69
|
+
<span fg={DANGER}>{GLYPHS.enter}</span>
|
|
70
|
+
{" confirm"}
|
|
71
|
+
</text>
|
|
72
|
+
<text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
|
|
73
|
+
<span fg={KEY_HINT}>{GLYPHS.esc}</span>
|
|
74
|
+
{" cancel"}
|
|
75
|
+
</text>
|
|
76
|
+
</box>
|
|
77
|
+
</box>
|
|
78
|
+
);
|
|
79
|
+
}
|