openk8s 0.0.1 → 1.0.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.
Files changed (35) hide show
  1. package/README.md +194 -40
  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,708 @@
1
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
2
+ import { useRenderer, useTerminalDimensions } from "@opentui/react";
3
+ import { useCallback, useMemo, useReducer, useRef, useState } from "react";
4
+
5
+ import { DEFAULT_NAMESPACE, initialState, reducer } from "./app-state";
6
+ import { loadPersistedState } from "./persistence";
7
+ import {
8
+ FILTER_BACKGROUND,
9
+ GLYPHS,
10
+ INSPECTOR_SURFACE,
11
+ KEY_HINT,
12
+ NAV_SURFACE,
13
+ PANEL_BORDER,
14
+ PANEL_BORDER_ACTIVE,
15
+ RESOURCE_SURFACE,
16
+ SURFACE,
17
+ SURFACE_ACCENT,
18
+ TEXT_MUTED,
19
+ TEXT_PRIMARY,
20
+ TEXT_SUBTLE,
21
+ paneBorder,
22
+ toneStyles,
23
+ } from "./theme";
24
+ import {
25
+ currentKindLabel,
26
+ filterResources,
27
+ nextVisibleResourceName,
28
+ resourcePreview,
29
+ selectedForwardsForRef,
30
+ selectedRef,
31
+ statusLine,
32
+ } from "./utils";
33
+ import { usePollingTick } from "./use-polling-tick";
34
+ import { InspectorBody, InspectorTabs, INSPECTOR_TABS } from "./components/inspector";
35
+ import { KindRows } from "./components/kind-rows";
36
+ import { ResourceRows } from "./components/resource-rows";
37
+ import {
38
+ DeleteConfirmOverlay,
39
+ HelmRollbackOverlay,
40
+ LogsDialog,
41
+ type LogsDialogActions,
42
+ PortForwardOverlay,
43
+ ScaleDialog,
44
+ SelectOverlay,
45
+ buildPortForwardEntries,
46
+ } from "./components/overlays";
47
+ import { PortForwardsTray } from "./components/port-forwards-tray";
48
+ import { NotificationTray } from "./components/notification-tray";
49
+ import { KubectlService } from "../lib/kubectl/kubectl-service";
50
+ import type {
51
+ ClusterContext,
52
+ NamespaceItem,
53
+ OverlayId,
54
+ PaneId,
55
+ ResourceDetailLine,
56
+ } from "../lib/k8s/types";
57
+ import type { NotificationTone } from "../lib/k8s/types";
58
+
59
+ import { useClipboard } from "./hooks/use-clipboard";
60
+ import { usePortForward } from "./hooks/use-port-forward";
61
+ import { useLogStream } from "./hooks/use-log-stream";
62
+ import { useDataFetching } from "./hooks/use-data-fetching";
63
+ import { useAppSideEffects } from "./hooks/use-app-side-effects";
64
+ import { useAppKeyboard } from "./hooks/use-app-keyboard";
65
+
66
+ const kubectl = new KubectlService();
67
+
68
+ const PANE_TITLES: Record<PaneId, string> = {
69
+ clusters: "Navigation",
70
+ resources: "Resources",
71
+ inspector: "Inspector",
72
+ };
73
+
74
+ const OVERLAY_TITLES: Record<OverlayId, string> = {
75
+ "cluster-switcher": "Cluster Switcher",
76
+ "namespace-switcher": "Namespace Switcher",
77
+ "delete-confirm": "Confirm Delete",
78
+ "port-forward": "Port Forward",
79
+ logs: "Logs",
80
+ scale: "Scale",
81
+ "container-picker": "Select Container",
82
+ "helm-rollback": "Helm Rollback",
83
+ };
84
+
85
+ export function App() {
86
+ const renderer = useRenderer();
87
+ const dims = useTerminalDimensions();
88
+ const [state, dispatch] = useReducer(reducer, undefined, (): typeof initialState => {
89
+ const { activeContext, activeNamespace } = loadPersistedState();
90
+ return {
91
+ ...initialState,
92
+ ...(activeContext !== undefined ? { activeContext } : {}),
93
+ ...(activeNamespace !== undefined ? { activeNamespace } : {}),
94
+ };
95
+ });
96
+ const [overlayIndex, setOverlayIndex] = useState(0);
97
+ const [manualRefreshNonce, setManualRefreshNonce] = useState(0);
98
+ const [filterMode, setFilterMode] = useState(false);
99
+ const [portForwardLocalPort, setPortForwardLocalPort] = useState("");
100
+ const [scaleReplicasInput, setScaleReplicasInput] = useState("");
101
+ const [logsInputMode, setLogsInputMode] = useState<"tail" | "since" | undefined>(undefined);
102
+ const [logsInputValue, setLogsInputValue] = useState("");
103
+ const [logsSearchMode, setLogsSearchMode] = useState(false);
104
+ const [logsSearchText, setLogsSearchText] = useState("");
105
+
106
+ const logsScrollRef = useRef<ScrollBoxRenderable | null>(null);
107
+ const logsActionsRef = useRef<LogsDialogActions | null>(null);
108
+ const notificationTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
109
+
110
+ const clusterPollTick = usePollingTick({ enabled: Boolean(state.activeContext), intervalMs: 30_000 });
111
+ const viewPollTick = usePollingTick({ enabled: Boolean(state.activeContext), intervalMs: 5_000 });
112
+ const metricsPollTick = usePollingTick({ enabled: Boolean(state.activeContext), intervalMs: 15_000 });
113
+
114
+ const selectedKind = useMemo(
115
+ () => state.resourceKinds.find((kind) => kind.name === state.selectedKind),
116
+ [state.resourceKinds, state.selectedKind],
117
+ );
118
+
119
+ const filteredResources = useMemo(
120
+ () => filterResources({ resources: state.resources, filter: state.resourceFilter }),
121
+ [state.resourceFilter, state.resources],
122
+ );
123
+
124
+ const visibleSelectedResourceName = useMemo(
125
+ () => nextVisibleResourceName({ resources: filteredResources, selectedName: state.selectedResourceName }),
126
+ [filteredResources, state.selectedResourceName],
127
+ );
128
+
129
+ const visibleSelectedResource = useMemo(
130
+ () => filteredResources.find((resource) => resource.ref.name === visibleSelectedResourceName),
131
+ [filteredResources, visibleSelectedResourceName],
132
+ );
133
+
134
+ const activeDetail = useMemo(() => {
135
+ if (!state.selectedResourceDetail || !visibleSelectedResource) {
136
+ return undefined;
137
+ }
138
+
139
+ return state.selectedResourceDetail.ref.name === visibleSelectedResource.ref.name &&
140
+ state.selectedResourceDetail.ref.kind === visibleSelectedResource.ref.kind &&
141
+ (state.selectedResourceDetail.ref.namespace ?? "") === (visibleSelectedResource.ref.namespace ?? "")
142
+ ? state.selectedResourceDetail
143
+ : undefined;
144
+ }, [state.selectedResourceDetail, visibleSelectedResource]);
145
+
146
+ const activeResourceRef = useMemo(
147
+ () => selectedRef({ detail: activeDetail, resource: visibleSelectedResource }),
148
+ [activeDetail, visibleSelectedResource],
149
+ );
150
+
151
+ const activeLogsRef = useMemo(
152
+ () =>
153
+ activeResourceRef && selectedKind
154
+ ? { kind: activeResourceRef.kind, name: activeResourceRef.name, namespaced: selectedKind.namespaced }
155
+ : undefined,
156
+ [activeResourceRef?.kind, activeResourceRef?.name, selectedKind?.namespaced],
157
+ );
158
+
159
+ const activeLogsTarget = useMemo(
160
+ () => (activeResourceRef ? `${activeResourceRef.kind}/${activeResourceRef.name}` : undefined),
161
+ [activeResourceRef],
162
+ );
163
+
164
+ const activeLogsKey = useMemo(
165
+ () =>
166
+ state.activeContext && activeLogsTarget && activeLogsRef
167
+ ? [
168
+ state.activeContext,
169
+ state.activeNamespace,
170
+ activeLogsTarget,
171
+ activeLogsRef.namespaced ? "ns" : "cluster",
172
+ state.logsContainer ?? "",
173
+ String(state.logsOptions.tail),
174
+ state.logsOptions.since ?? "",
175
+ state.logsOptions.previous ? "1" : "0",
176
+ ].join(":")
177
+ : undefined,
178
+ [state.activeContext, state.activeNamespace, activeLogsRef, activeLogsTarget, state.logsContainer, state.logsOptions],
179
+ );
180
+
181
+ const selectedResourcePortForwards = useMemo(
182
+ () => selectedForwardsForRef({ forwards: state.activePortForwards, ref: activeResourceRef }),
183
+ [state.activePortForwards, activeResourceRef],
184
+ );
185
+
186
+ const selectedResources = useMemo(
187
+ () => state.resources.filter((resource) => state.selectedResourceNames.includes(resource.ref.name)),
188
+ [state.resources, state.selectedResourceNames],
189
+ );
190
+
191
+ const deleteTargets = useMemo(
192
+ () => (selectedResources.length > 0 ? selectedResources : visibleSelectedResource ? [visibleSelectedResource] : []),
193
+ [selectedResources, visibleSelectedResource],
194
+ );
195
+
196
+ const activeForwardKeys = useMemo(
197
+ () => new Set(state.activePortForwards.map((f) => `${f.ref.kind.toLowerCase()}:${f.ref.name}`)),
198
+ [state.activePortForwards],
199
+ );
200
+
201
+ const footerPalette = toneStyles("neutral");
202
+
203
+ // ── Hooks ─────────────────────────────────────────────────────────────────
204
+
205
+ const toast = useCallback((tone: NotificationTone, message: string): void => {
206
+ const id = `notif-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
207
+ dispatch({ type: "pushNotification", notification: { id, tone, message } });
208
+ const timer = setTimeout(() => {
209
+ notificationTimersRef.current.delete(id);
210
+ dispatch({ type: "dismissNotification", id });
211
+ }, 2_500);
212
+ notificationTimersRef.current.set(id, timer);
213
+ }, []);
214
+
215
+ const { copyToClipboard, toastError } = useClipboard(dispatch, toast);
216
+
217
+ const { portForwardProcessesRef, stopPortForward, startPortForward, openPortForwardOverlay: rawOpenPortForwardOverlay } = usePortForward(dispatch, kubectl, toastError);
218
+
219
+ const logStreamRef = useLogStream(dispatch, state, activeLogsRef, activeLogsTarget, activeLogsKey, kubectl);
220
+
221
+ const { resourcesLengthRef, selectedResourceNameRef, selectedResourceDetailRef, eventsLengthRef } = useDataFetching(
222
+ state,
223
+ dispatch,
224
+ selectedKind,
225
+ activeResourceRef,
226
+ viewPollTick,
227
+ clusterPollTick,
228
+ metricsPollTick,
229
+ manualRefreshNonce,
230
+ filteredResources,
231
+ kubectl,
232
+ setFilterMode,
233
+ );
234
+
235
+ useAppSideEffects(state, dispatch, portForwardProcessesRef, notificationTimersRef, activeResourceRef);
236
+
237
+ const openPortForwardOverlay = useCallback(() => {
238
+ rawOpenPortForwardOverlay(activeDetail, selectedResourcePortForwards, setPortForwardLocalPort, setOverlayIndex, dispatch);
239
+ }, [activeDetail, selectedResourcePortForwards, setPortForwardLocalPort, setOverlayIndex, dispatch, rawOpenPortForwardOverlay]);
240
+
241
+ const handleToggleReveal = useCallback(
242
+ (id: string): void => {
243
+ const allLines = activeDetail?.summarySections.flatMap((s) => s.lines) ?? [];
244
+ const line = allLines.find((l): l is ResourceDetailLine => typeof l !== "string" && l.id === id);
245
+
246
+ dispatch({ type: "toggleRevealDetailLine", id });
247
+
248
+ if (line?.secretRef && !state.revealedSecretValues[id] && state.activeContext) {
249
+ const context = state.activeContext;
250
+ const namespace = state.activeNamespace;
251
+ const { name, key } = line.secretRef;
252
+
253
+ void (async () => {
254
+ try {
255
+ let value: string;
256
+
257
+ if (key !== undefined) {
258
+ value = await kubectl.getSecretValue({ context, namespace, name, key });
259
+ } else {
260
+ const allValues = await kubectl.getAllSecretValues({ context, namespace, name });
261
+ value = Object.entries(allValues)
262
+ .map(([k, v]) => `${k}=${v}`)
263
+ .join(" ");
264
+ }
265
+
266
+ dispatch({ type: "setRevealedSecretValue", id, value });
267
+ } catch (error: unknown) {
268
+ toastError(error);
269
+ }
270
+ })();
271
+ }
272
+ },
273
+ [activeDetail, state.revealedSecretValues, state.activeContext, state.activeNamespace, toastError],
274
+ );
275
+
276
+ useAppKeyboard(
277
+ state,
278
+ dispatch,
279
+ renderer,
280
+ filteredResources,
281
+ visibleSelectedResourceName,
282
+ activeDetail,
283
+ activeResourceRef,
284
+ activeLogsRef,
285
+ selectedKind,
286
+ deleteTargets,
287
+ selectedResourcePortForwards,
288
+ portForwardProcessesRef,
289
+ kubectl,
290
+ filterMode,
291
+ overlayIndex,
292
+ portForwardLocalPort,
293
+ scaleReplicasInput,
294
+ logsInputMode,
295
+ logsInputValue,
296
+ logsSearchMode,
297
+ setOverlayIndex,
298
+ setFilterMode,
299
+ setPortForwardLocalPort,
300
+ setScaleReplicasInput,
301
+ setLogsInputMode,
302
+ setLogsInputValue,
303
+ setLogsSearchMode,
304
+ setLogsSearchText,
305
+ setManualRefreshNonce,
306
+ stopPortForward,
307
+ startPortForward,
308
+ openPortForwardOverlay,
309
+ toast,
310
+ toastError,
311
+ copyToClipboard,
312
+ handleToggleReveal,
313
+ logsScrollRef,
314
+ logsActionsRef,
315
+ );
316
+
317
+ // ── Derived overlay data ───────────────────────────────────────────────────
318
+
319
+ const overlayItems =
320
+ state.overlay === "cluster-switcher"
321
+ ? state.contexts.map((context: ClusterContext) => ({
322
+ name: context.name,
323
+ description: context.name === state.activeContext ? "active context" : "available context",
324
+ }))
325
+ : state.overlay === "namespace-switcher"
326
+ ? state.namespaces.map((namespace: NamespaceItem) => ({
327
+ name: namespace.name,
328
+ description: namespace.name === state.activeNamespace ? "active namespace" : "namespace",
329
+ }))
330
+ : state.overlay === "container-picker"
331
+ ? (activeDetail?.containers ?? []).map((name) => ({
332
+ name,
333
+ description: name === state.logsContainer ? "currently selected" : "container",
334
+ }))
335
+ : [];
336
+
337
+ // ── Footer hints (context-aware) ─────────────────────────────────────────
338
+
339
+ const footerHints = useMemo((): [string, string][] => {
340
+ // Filter mode: only show filter-specific controls
341
+ if (filterMode) {
342
+ return [
343
+ [GLYPHS.enter, "confirm"],
344
+ [GLYPHS.esc, "cancel"],
345
+ ["^U", "clear"],
346
+ ];
347
+ }
348
+
349
+ const isHelm = activeResourceRef?.kind.toLowerCase() === "helmreleases";
350
+ const hasResource = Boolean(activeResourceRef);
351
+ const hasPortForwards =
352
+ (activeDetail?.portForwards?.length ?? 0) > 0 || selectedResourcePortForwards.length > 0;
353
+
354
+ // Clusters pane: kind-list navigation only
355
+ if (state.activePane === "clusters") {
356
+ return [
357
+ [GLYPHS.tab, "panes"],
358
+ ["↑↓", "kinds"],
359
+ ["R", "refresh"],
360
+ ];
361
+ }
362
+
363
+ // Inspector pane: navigation back, refresh
364
+ if (state.activePane === "inspector") {
365
+ return [
366
+ [GLYPHS.tab, "panes"],
367
+ ["←", "back"],
368
+ ["R", "refresh"],
369
+ ];
370
+ }
371
+
372
+ // Resources pane — base hints always present
373
+ const hints: [string, string][] = [
374
+ [GLYPHS.tab, "panes"],
375
+ ["↑↓", "navigate"],
376
+ ["/", "filter"],
377
+ ["R", "refresh"],
378
+ ];
379
+
380
+ if (!hasResource) return hints;
381
+
382
+ if (isHelm) {
383
+ hints.push(
384
+ ["E", "edit values"],
385
+ ...(deleteTargets.length > 0 ? [["D", "delete"] as [string, string]] : []),
386
+ ["B", "rollback"],
387
+ ["U", "upgrade"],
388
+ );
389
+ } else {
390
+ hints.push(["L", "logs"], ["E", "edit"], ["X", "shell"]);
391
+ if (hasPortForwards) hints.push(["F", "forward"]);
392
+ if (deleteTargets.length > 0) hints.push(["D", "delete"]);
393
+ if (activeResourceRef && kubectl.canScale(activeResourceRef)) hints.push(["⇧S", "scale"]);
394
+ if (activeResourceRef && kubectl.canRolloutRestart(activeResourceRef)) hints.push(["⇧R", "restart"]);
395
+ }
396
+
397
+ return hints;
398
+ }, [
399
+ filterMode,
400
+ state.activePane,
401
+ activeResourceRef,
402
+ activeDetail,
403
+ selectedResourcePortForwards,
404
+ deleteTargets,
405
+ ]);
406
+
407
+ // Split footerHints across two rows so hints never overflow.
408
+ // Row 1: primary hints, Row 2: overflow hints.
409
+ // Each badge: key.length + label.length + 3 chars; columnGap: 2 between items.
410
+ const { footerHintsRow1, footerHintsRow2 } = useMemo(() => {
411
+ const innerWidth = dims.width - 4; // 2 border + 2 padding
412
+ const avail1 = innerWidth;
413
+ const avail2 = innerWidth;
414
+ const row1: [string, string][] = [];
415
+ const row2: [string, string][] = [];
416
+ let used1 = 0;
417
+ let used2 = 0;
418
+ for (const [key, label] of footerHints) {
419
+ const w = key.length + label.length + 3;
420
+ const gap1 = row1.length === 0 ? 0 : 2;
421
+ const gap2 = row2.length === 0 ? 0 : 2;
422
+ if (used1 + gap1 + w <= avail1) {
423
+ used1 += gap1 + w;
424
+ row1.push([key, label]);
425
+ } else if (used2 + gap2 + w <= avail2) {
426
+ used2 += gap2 + w;
427
+ row2.push([key, label]);
428
+ }
429
+ // else drop — can't fit either row
430
+ }
431
+ return { footerHintsRow1: row1, footerHintsRow2: row2 };
432
+ }, [footerHints, dims.width]);
433
+
434
+ // ── Render ────────────────────────────────────────────────────────────────
435
+
436
+ return (
437
+ <box style={{ flexDirection: "column", flexGrow: 1, backgroundColor: SURFACE }}>
438
+ {/* Header bar */}
439
+ <box
440
+ onMouseDown={() => dispatch({ type: "setActivePane", pane: "clusters" })}
441
+ style={{
442
+ height: 3,
443
+ border: true,
444
+ borderColor: PANEL_BORDER,
445
+ borderStyle: "rounded",
446
+ backgroundColor: SURFACE_ACCENT,
447
+ paddingLeft: 1,
448
+ paddingRight: 1,
449
+ justifyContent: "center",
450
+ alignItems: "center",
451
+ flexDirection: "row",
452
+ }}
453
+ >
454
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
455
+ {state.activeContext
456
+ ? `${state.activeContext} / ${state.activeNamespace} / ${selectedKind?.name ?? "..."}`
457
+ : state.statusMessage}
458
+ </text>
459
+ </box>
460
+
461
+ {/* Footer bar — status + hotkeys across two rows */}
462
+ <box
463
+ style={{
464
+ height: 4,
465
+ border: true,
466
+ borderColor: footerPalette.border,
467
+ borderStyle: "rounded",
468
+ backgroundColor: footerPalette.bg,
469
+ paddingLeft: 1,
470
+ paddingRight: 1,
471
+ flexDirection: "column",
472
+ }}
473
+ >
474
+ {/* Row 1: first set of key hints */}
475
+ <box style={{ flexDirection: "row", justifyContent: "flex-end", alignItems: "center", flexGrow: 1 }}>
476
+ <box style={{ flexDirection: "row", columnGap: 2 }}>
477
+ {footerHintsRow1.map(([key, label]) => (
478
+ <text key={`${key}:${label}`} fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
479
+ {"["}
480
+ <span fg={KEY_HINT}>{key}</span>
481
+ {`] ${label}`}
482
+ </text>
483
+ ))}
484
+ </box>
485
+ </box>
486
+ {/* Row 2: overflow hints, right-aligned */}
487
+ <box style={{ flexDirection: "row", justifyContent: "flex-end", alignItems: "center", flexGrow: 1 }}>
488
+ <box style={{ flexDirection: "row", columnGap: 2 }}>
489
+ {footerHintsRow2.map(([key, label]) => (
490
+ <text key={`${key}:${label}`} fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
491
+ {"["}
492
+ <span fg={KEY_HINT}>{key}</span>
493
+ {`] ${label}`}
494
+ </text>
495
+ ))}
496
+ </box>
497
+ </box>
498
+ </box>
499
+
500
+ {/* Main content area */}
501
+ <box style={{ flexGrow: 1, flexDirection: "row", columnGap: 1, padding: 1, backgroundColor: SURFACE }}>
502
+ {/* Navigation pane */}
503
+ <box
504
+ title={PANE_TITLES.clusters}
505
+ onMouseDown={() => dispatch({ type: "setActivePane", pane: "clusters" })}
506
+ style={{
507
+ width: 34,
508
+ border: true,
509
+ borderColor: paneBorder(state.activePane === "clusters"),
510
+ borderStyle: "rounded",
511
+ backgroundColor: NAV_SURFACE,
512
+ flexDirection: "column",
513
+ padding: 1,
514
+ }}
515
+ >
516
+ <text fg={TEXT_PRIMARY}>
517
+ <span fg={KEY_HINT}>{GLYPHS.cluster}</span>
518
+ {` ${state.activeContext ?? "none"}`}
519
+ </text>
520
+ <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
521
+ <span fg={KEY_HINT}>{GLYPHS.ns}</span>
522
+ {` ${state.activeNamespace}`}
523
+ </text>
524
+ <KindRows
525
+ resourceKinds={state.resourceKinds}
526
+ selectedKind={state.selectedKind}
527
+ onActivate={() => dispatch({ type: "setActivePane", pane: "clusters" })}
528
+ onSelect={(kind) => dispatch({ type: "setSelectedKind", kind })}
529
+ />
530
+ <text fg={TEXT_SUBTLE} attributes={TEXT_MUTED}>
531
+ {"["}
532
+ <span fg={KEY_HINT}>C</span>
533
+ {"] cluster ["}
534
+ <span fg={KEY_HINT}>N</span>
535
+ {"] namespace"}
536
+ </text>
537
+ </box>
538
+
539
+ {/* Resources pane */}
540
+ <box
541
+ title={`${PANE_TITLES.resources}: ${currentKindLabel({ resourceKinds: state.resourceKinds, selectedKind: state.selectedKind })}`}
542
+ onMouseDown={() => dispatch({ type: "setActivePane", pane: "resources" })}
543
+ style={{
544
+ flexGrow: 1,
545
+ border: true,
546
+ borderColor: paneBorder(state.activePane === "resources"),
547
+ borderStyle: "rounded",
548
+ backgroundColor: RESOURCE_SURFACE,
549
+ flexDirection: "column",
550
+ padding: 1,
551
+ }}
552
+ >
553
+ <box
554
+ onMouseDown={() => {
555
+ dispatch({ type: "setActivePane", pane: "resources" });
556
+ setFilterMode(true);
557
+ }}
558
+ style={{
559
+ border: true,
560
+ borderColor: filterMode || state.activePane === "resources" ? PANEL_BORDER_ACTIVE : PANEL_BORDER,
561
+ borderStyle: "rounded",
562
+ backgroundColor: FILTER_BACKGROUND,
563
+ paddingLeft: 1,
564
+ paddingRight: 1,
565
+ marginBottom: 1,
566
+ height: 3,
567
+ justifyContent: "center",
568
+ alignItems: "center",
569
+ flexDirection: "row",
570
+ }}
571
+ >
572
+ <text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
573
+ Filter:
574
+ </text>
575
+ <input
576
+ focused={filterMode}
577
+ value={state.resourceFilter}
578
+ placeholder="type to filter resources"
579
+ style={{ flexGrow: 1, marginLeft: 1 }}
580
+ onInput={(value) => dispatch({ type: "setResourceFilter", value })}
581
+ onChange={(value) => dispatch({ type: "setResourceFilter", value })}
582
+ onSubmit={() => setFilterMode(false)}
583
+ />
584
+ </box>
585
+ <ResourceRows
586
+ resources={filteredResources}
587
+ selectedName={visibleSelectedResourceName}
588
+ selectedNames={state.selectedResourceNames}
589
+ status={state.resourcesStatus}
590
+ filter={state.resourceFilter}
591
+ activeForwardKeys={activeForwardKeys}
592
+ podMetrics={state.podMetrics}
593
+ nodeMetrics={state.nodeMetrics}
594
+ onActivate={() => dispatch({ type: "setActivePane", pane: "resources" })}
595
+ onSelect={(name) => dispatch({ type: "setSelectedResourceName", name })}
596
+ onToggleSelected={(name) => dispatch({ type: "toggleSelectedResource", name })}
597
+ />
598
+ </box>
599
+
600
+ {/* Inspector pane — hidden when terminal is too narrow */}
601
+ {dims.width >= 110 && (
602
+ <box
603
+ title={PANE_TITLES.inspector}
604
+ onMouseDown={() => dispatch({ type: "setActivePane", pane: "inspector" })}
605
+ style={{
606
+ width: 52,
607
+ border: true,
608
+ borderColor: paneBorder(state.activePane === "inspector"),
609
+ borderStyle: "rounded",
610
+ backgroundColor: INSPECTOR_SURFACE,
611
+ flexDirection: "column",
612
+ padding: 1,
613
+ }}
614
+ >
615
+ <InspectorTabs
616
+ activeTab={state.inspectorTab}
617
+ activePane={state.activePane}
618
+ onSelect={(tab) => {
619
+ dispatch({ type: "setActivePane", pane: "inspector" });
620
+ dispatch({ type: "setInspectorTab", tab });
621
+ }}
622
+ />
623
+ <InspectorBody
624
+ activeTab={state.inspectorTab}
625
+ detail={activeDetail}
626
+ detailStatus={state.selectedResourceDetailStatus}
627
+ events={state.events}
628
+ eventsStatus={state.eventsStatus}
629
+ fallbackLines={resourcePreview({ resource: visibleSelectedResource })}
630
+ active={state.activePane === "inspector"}
631
+ onActivate={() => dispatch({ type: "setActivePane", pane: "inspector" })}
632
+ revealedDetailLineIds={state.revealedDetailLineIds}
633
+ revealedSecretValues={state.revealedSecretValues}
634
+ onToggleReveal={handleToggleReveal}
635
+ onCopyText={copyToClipboard}
636
+ />
637
+ </box>
638
+ )}
639
+ </box>
640
+
641
+ <PortForwardsTray forwards={state.activePortForwards} onStop={stopPortForward} />
642
+
643
+ <NotificationTray
644
+ notifications={state.notifications}
645
+ onDismiss={(id) => {
646
+ const timer = notificationTimersRef.current.get(id);
647
+ if (timer) {
648
+ clearTimeout(timer);
649
+ notificationTimersRef.current.delete(id);
650
+ }
651
+ dispatch({ type: "dismissNotification", id });
652
+ }}
653
+ />
654
+
655
+ {/* Overlays */}
656
+ {state.overlay ? (
657
+ state.overlay === "delete-confirm" ? (
658
+ <DeleteConfirmOverlay targets={deleteTargets.map((resource) => resource.ref)} />
659
+ ) : state.overlay === "logs" ? (
660
+ <LogsDialog
661
+ target={state.logsTarget}
662
+ logsText={state.logsText}
663
+ status={state.logsStatus}
664
+ scrollRef={logsScrollRef}
665
+ logsOptions={state.logsOptions}
666
+ logsContainer={state.logsContainer}
667
+ containers={activeDetail?.containers}
668
+ inputMode={logsInputMode}
669
+ inputValue={logsInputValue}
670
+ onOptionInputChange={setLogsInputValue}
671
+ searchMode={logsSearchMode}
672
+ searchText={logsSearchText}
673
+ onSearchChange={setLogsSearchText}
674
+ actionsRef={logsActionsRef}
675
+ />
676
+ ) : state.overlay === "port-forward" && activeResourceRef ? (
677
+ <PortForwardOverlay
678
+ target={statusLine({ ref: activeResourceRef })}
679
+ entries={buildPortForwardEntries(activeDetail?.portForwards ?? [], selectedResourcePortForwards)}
680
+ selectedIndex={overlayIndex}
681
+ localPortValue={portForwardLocalPort}
682
+ onChange={setPortForwardLocalPort}
683
+ />
684
+ ) : state.overlay === "scale" && activeResourceRef ? (
685
+ <ScaleDialog
686
+ target={statusLine({ ref: activeResourceRef })}
687
+ currentReplicas={activeDetail?.replicas}
688
+ replicasValue={scaleReplicasInput}
689
+ onChange={setScaleReplicasInput}
690
+ />
691
+ ) : state.overlay === "helm-rollback" && activeResourceRef ? (
692
+ <HelmRollbackOverlay
693
+ releaseName={activeResourceRef.name}
694
+ revisionValue={state.helmRollbackRevision}
695
+ onChange={(value) => dispatch({ type: "setHelmRollbackRevision", value })}
696
+ />
697
+ ) : (
698
+ <SelectOverlay
699
+ title={OVERLAY_TITLES[state.overlay]}
700
+ items={overlayItems}
701
+ selectedIndex={overlayIndex}
702
+ onChange={setOverlayIndex}
703
+ />
704
+ )
705
+ ) : undefined}
706
+ </box>
707
+ );
708
+ }