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,149 @@
1
+ import { useRef } from "react";
2
+ import type { ChildProcess } from "node:child_process";
3
+
4
+ import type { AppAction } from "../app-state";
5
+ import type { ActivePortForward, ResourceDetail, ResourceRef } from "../../lib/k8s/types";
6
+ import type { StartPortForwardLifecycleOptions } from "../utils";
7
+ import { portForwardId, statusLine } from "../utils";
8
+ import { KubectlService } from "../../lib/kubectl/kubectl-service";
9
+
10
+ export function usePortForward(
11
+ dispatch: React.Dispatch<AppAction>,
12
+ kubectl: KubectlService,
13
+ toastError: (error: unknown) => void,
14
+ ) {
15
+ const portForwardProcessesRef = useRef(new Map<string, ChildProcess>());
16
+
17
+ const stopPortForward = (id: string): void => {
18
+ const child = portForwardProcessesRef.current.get(id);
19
+
20
+ if (!child) {
21
+ dispatch({ type: "removeActivePortForward", id });
22
+ return;
23
+ }
24
+
25
+ child.kill("SIGINT");
26
+ };
27
+
28
+ const openPortForwardOverlay = (
29
+ activeDetail: ResourceDetail | undefined,
30
+ selectedResourcePortForwards: ActivePortForward[],
31
+ setPortForwardLocalPort: (value: string) => void,
32
+ setOverlayIndex: (value: number) => void,
33
+ dispatch: React.Dispatch<AppAction>,
34
+ ): void => {
35
+ const portForwards = activeDetail?.portForwards ?? [];
36
+ const firstInactive = portForwards.find(
37
+ (pf) => !selectedResourcePortForwards.some((af) => af.remotePort === pf.remotePort),
38
+ );
39
+ setPortForwardLocalPort(String((firstInactive ?? portForwards[0])?.localPort ?? ""));
40
+ setOverlayIndex(0);
41
+ dispatch({ type: "setOverlay", overlay: "port-forward" });
42
+ };
43
+
44
+ const startPortForward = (options: StartPortForwardLifecycleOptions): void => {
45
+ const id = portForwardId({
46
+ context: options.context,
47
+ namespace: options.namespace,
48
+ ref: options.target,
49
+ localPort: options.localPort,
50
+ remotePort: options.remotePort,
51
+ });
52
+
53
+ if (portForwardProcessesRef.current.has(id)) {
54
+ dispatch({
55
+ type: "setStatusMessage",
56
+ message: `Already forwarding ${statusLine({ ref: options.target })} on localhost:${options.localPort}`,
57
+ });
58
+ dispatch({ type: "setOverlay", overlay: undefined });
59
+ return;
60
+ }
61
+
62
+ const forward: ActivePortForward = {
63
+ id,
64
+ context: options.context,
65
+ namespace: options.namespace,
66
+ namespaced: options.namespaced,
67
+ ref: options.target,
68
+ localPort: options.localPort,
69
+ remotePort: options.remotePort,
70
+ status: "starting",
71
+ };
72
+
73
+ dispatch({ type: "addActivePortForward", forward });
74
+ dispatch({
75
+ type: "setStatusMessage",
76
+ message: `Starting forward for ${statusLine({ ref: options.target })} on localhost:${options.localPort}`,
77
+ });
78
+ dispatch({ type: "setError", error: undefined });
79
+ dispatch({ type: "setOverlay", overlay: undefined });
80
+
81
+ const child = kubectl.startPortForward({
82
+ context: options.context,
83
+ namespace: options.namespace,
84
+ resourceRef: options.target,
85
+ namespaced: options.namespaced,
86
+ localPort: options.localPort,
87
+ remotePort: options.remotePort,
88
+ });
89
+
90
+ portForwardProcessesRef.current.set(id, child);
91
+
92
+ const handleOutput = (chunk: Buffer | string): void => {
93
+ const text = chunk.toString();
94
+
95
+ if (text.includes("Forwarding from")) {
96
+ dispatch({
97
+ type: "updateActivePortForward",
98
+ id,
99
+ patch: { status: "ready", message: text.trim() },
100
+ });
101
+ dispatch({
102
+ type: "setStatusMessage",
103
+ message: `Forwarding ${statusLine({ ref: options.target })} on localhost:${options.localPort} -> ${options.remotePort}`,
104
+ });
105
+ }
106
+ };
107
+
108
+ child.stdout?.on("data", handleOutput);
109
+ child.stderr?.on("data", handleOutput);
110
+
111
+ child.on("close", (code, signal) => {
112
+ portForwardProcessesRef.current.delete(id);
113
+ dispatch({ type: "removeActivePortForward", id });
114
+
115
+ if (code === 0 || code === 130 || signal === "SIGINT") {
116
+ dispatch({ type: "setError", error: undefined });
117
+ dispatch({
118
+ type: "setStatusMessage",
119
+ message: `Stopped forwarding ${statusLine({ ref: options.target })} on localhost:${options.localPort}`,
120
+ });
121
+ return;
122
+ }
123
+
124
+ dispatch({
125
+ type: "setError",
126
+ error: {
127
+ message: `Port-forward failed for ${statusLine({ ref: options.target })}`,
128
+ detail: code !== null ? `Exit code ${code}` : signal ? `Signal ${signal}` : undefined,
129
+ },
130
+ });
131
+ dispatch({
132
+ type: "setStatusMessage",
133
+ message: `Port-forward failed for ${statusLine({ ref: options.target })}`,
134
+ });
135
+ });
136
+
137
+ child.on("error", (error) => {
138
+ portForwardProcessesRef.current.delete(id);
139
+ dispatch({ type: "removeActivePortForward", id });
140
+ toastError(error);
141
+ dispatch({
142
+ type: "setStatusMessage",
143
+ message: `Port-forward failed for ${statusLine({ ref: options.target })}`,
144
+ });
145
+ });
146
+ };
147
+
148
+ return { portForwardProcessesRef, stopPortForward, startPortForward, openPortForwardOverlay };
149
+ }
@@ -0,0 +1,44 @@
1
+ import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+
4
+ export interface PersistedAppState {
5
+ activeContext?: string | undefined;
6
+ activeNamespace?: string | undefined;
7
+ }
8
+
9
+ function configPath(): string {
10
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
11
+ return join(home, ".config", "openk8s", "state.json");
12
+ }
13
+
14
+ /** Synchronously loads persisted context/namespace from disk. Returns {} on any error. */
15
+ export function loadPersistedState(): PersistedAppState {
16
+ try {
17
+ const text = readFileSync(configPath(), "utf8");
18
+ const parsed = JSON.parse(text) as unknown;
19
+
20
+ if (typeof parsed !== "object" || parsed === null) {
21
+ return {};
22
+ }
23
+
24
+ const data = parsed as Record<string, unknown>;
25
+
26
+ return {
27
+ activeContext: typeof data.activeContext === "string" ? data.activeContext : undefined,
28
+ activeNamespace: typeof data.activeNamespace === "string" ? data.activeNamespace : undefined,
29
+ };
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ /** Best-effort write of context/namespace to disk. Errors are silently ignored. */
36
+ export function savePersistedState(state: PersistedAppState): void {
37
+ try {
38
+ const path = configPath();
39
+ mkdirSync(dirname(path), { recursive: true });
40
+ writeFileSync(path, JSON.stringify(state, null, 2), "utf8");
41
+ } catch {
42
+ // Best-effort; ignore write errors
43
+ }
44
+ }
@@ -0,0 +1,95 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+
3
+ export type Tone = "neutral" | "info" | "success" | "warning" | "danger" | "accent";
4
+
5
+ export interface ToneStyle {
6
+ border: string;
7
+ bg: string;
8
+ fg: string;
9
+ }
10
+
11
+ // ── Surfaces ─────────────────────────────────────────────────────────────────
12
+ export const SURFACE = "#0b0f1a";
13
+ export const SURFACE_ACCENT = "#0f1524";
14
+ export const NAV_SURFACE = "#0d1320";
15
+ export const RESOURCE_SURFACE = "#0c1422";
16
+ export const INSPECTOR_SURFACE = "#0d1525";
17
+ export const OVERLAY_SURFACE = "#111827";
18
+
19
+ // ── Borders ───────────────────────────────────────────────────────────────────
20
+ // Active border uses the official Kubernetes blue (#326CE5)
21
+ export const PANEL_BORDER = "#1e3558";
22
+ export const PANEL_BORDER_ACTIVE = "#326ce5";
23
+
24
+ // ── Selection rows ────────────────────────────────────────────────────────────
25
+ export const ROW_SELECTED = "#152040";
26
+ export const ROW_SELECTED_ALT = "#111c38";
27
+
28
+ // ── Text ──────────────────────────────────────────────────────────────────────
29
+ export const TEXT_MUTED = TextAttributes.DIM;
30
+ export const TEXT_PRIMARY = "#d4e1f7";
31
+ export const TEXT_SUBTLE = "#6474a0";
32
+ export const FILTER_BACKGROUND = "#090e1a";
33
+
34
+ // ── Semantic tone colors ──────────────────────────────────────────────────────
35
+ export const INFO = "#4a90d9";
36
+ export const SUCCESS = "#4a9e72";
37
+ export const WARNING = "#b08a3a";
38
+ export const DANGER = "#a05050";
39
+ export const ACCENT = "#5b8edb";
40
+
41
+ // Key-hint accent (for [X] badge letter)
42
+ export const KEY_HINT = "#5b8edb";
43
+
44
+ // ── YAML colorizer ────────────────────────────────────────────────────────────
45
+ export const YAML_KEY = "#7aa2f7";
46
+ export const YAML_VALUE = "#9ece6a";
47
+ export const YAML_COMMENT = "#4e5f88";
48
+
49
+ // ── Unicode glyphs ────────────────────────────────────────────────────────────
50
+ export const GLYPHS = {
51
+ // Row selection / multi-select
52
+ cursor: "▶", // active row cursor
53
+ marked: "◆", // multi-select on
54
+ unmarked: "◇", // multi-select off
55
+
56
+ // Status dots
57
+ dot: "●", // healthy / ready
58
+ dotEmpty: "○", // pending / starting
59
+ cross: "✗", // error / delete
60
+
61
+ // Event type
62
+ warn: "▲", // warning event
63
+
64
+ // Actions
65
+ stop: "×", // port-forward stop
66
+ forward: "⇄", // port-forward icon
67
+
68
+ // Key hints
69
+ enter: "↵", // confirm
70
+ esc: "⎋", // cancel / close
71
+ tab: "⇥", // tab key
72
+
73
+ // Inline decorators
74
+ sep: "│", // inline separator
75
+ cluster: "◈", // cluster header icon
76
+ ns: "⊡", // namespace header icon
77
+ } as const;
78
+
79
+ // ── Tone palette ──────────────────────────────────────────────────────────────
80
+ const TONE_STYLES: Record<Tone, ToneStyle> = {
81
+ neutral: { border: PANEL_BORDER, bg: "#12171d", fg: TEXT_SUBTLE },
82
+ info: { border: INFO, bg: "#101e30", fg: "#7ab3e8" },
83
+ success: { border: SUCCESS, bg: "#0f1d18", fg: "#7abf9e" },
84
+ warning: { border: WARNING, bg: "#1d1810", fg: "#c9a85a" },
85
+ danger: { border: DANGER, bg: "#1d1010", fg: "#c98080" },
86
+ accent: { border: ACCENT, bg: "#101a2e", fg: "#88aaee" },
87
+ };
88
+
89
+ export function toneStyles(tone: Tone): ToneStyle {
90
+ return TONE_STYLES[tone];
91
+ }
92
+
93
+ export function paneBorder(active: boolean): string {
94
+ return active ? PANEL_BORDER_ACTIVE : PANEL_BORDER;
95
+ }
@@ -0,0 +1,27 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export interface UsePollingTickOptions {
4
+ enabled: boolean;
5
+ intervalMs: number;
6
+ }
7
+
8
+ /** Returns a counter that increments every intervalMs when enabled. Use as an effect dependency to trigger periodic re-fetches. */
9
+ export function usePollingTick(options: UsePollingTickOptions): number {
10
+ const [tick, setTick] = useState(0);
11
+
12
+ useEffect(() => {
13
+ if (!options.enabled) {
14
+ return;
15
+ }
16
+
17
+ const interval = setInterval(() => {
18
+ setTick((current) => current + 1);
19
+ }, options.intervalMs);
20
+
21
+ return () => {
22
+ clearInterval(interval);
23
+ };
24
+ }, [options.enabled, options.intervalMs]);
25
+
26
+ return tick;
27
+ }
@@ -0,0 +1,274 @@
1
+ import { DEFAULT_NAMESPACE } from "./app-state";
2
+ import type { Tone } from "./theme";
3
+ import type {
4
+ ActivePortForward,
5
+ LoadStatus,
6
+ NamespaceItem,
7
+ ResourceDetail,
8
+ ResourceDetailLine,
9
+ ResourceKind,
10
+ ResourceListItem,
11
+ ResourceRef,
12
+ } from "../lib/k8s/types";
13
+
14
+ export type ActionId = "open-shell" | "port-forward" | "toggle-logs" | "delete";
15
+
16
+ export interface SelectedRefOptions {
17
+ detail?: ResourceDetail | undefined;
18
+ resource?: ResourceListItem | undefined;
19
+ }
20
+
21
+ export interface SelectedForwardsForRefOptions {
22
+ forwards: ActivePortForward[];
23
+ ref?: ResourceRef | undefined;
24
+ }
25
+
26
+ export interface StatusLineOptions {
27
+ ref?: ResourceRef | undefined;
28
+ }
29
+
30
+ export interface ResourceDetailTextResult {
31
+ id: string;
32
+ text: string;
33
+ revealable: boolean;
34
+ }
35
+
36
+ export interface DefaultNamespaceOptions {
37
+ namespaces: NamespaceItem[];
38
+ currentNamespace: string;
39
+ }
40
+
41
+ export interface FilterResourcesOptions {
42
+ resources: ResourceListItem[];
43
+ filter: string;
44
+ }
45
+
46
+ export interface NextVisibleResourceNameOptions {
47
+ resources: ResourceListItem[];
48
+ selectedName?: string | undefined;
49
+ }
50
+
51
+ export interface ResourcePreviewOptions {
52
+ resource?: ResourceListItem | undefined;
53
+ }
54
+
55
+ export interface CurrentKindLabelOptions {
56
+ resourceKinds: ResourceKind[];
57
+ selectedKind: string;
58
+ }
59
+
60
+ export interface PortForwardIdOptions {
61
+ context: string;
62
+ namespace: string;
63
+ ref: ResourceRef;
64
+ localPort: number;
65
+ remotePort: number;
66
+ }
67
+
68
+ export interface StartPortForwardLifecycleOptions {
69
+ context: string;
70
+ namespace: string;
71
+ target: ResourceRef;
72
+ namespaced: boolean;
73
+ localPort: number;
74
+ remotePort: number;
75
+ }
76
+
77
+ /** Converts an unknown thrown value into a displayable error object. Bug-fix: populates detail from error.cause. */
78
+ export function loadError(error: unknown): { message: string; detail?: string | undefined } {
79
+ if (error instanceof Error) {
80
+ const detail = error.cause !== undefined ? String(error.cause) : undefined;
81
+ return { message: error.message, detail };
82
+ }
83
+
84
+ return { message: "Unexpected error" };
85
+ }
86
+
87
+ export function statusLine(options: StatusLineOptions): string {
88
+ if (!options.ref) {
89
+ return "No selection";
90
+ }
91
+
92
+ return `${options.ref.kind} ${options.ref.namespace ? `${options.ref.namespace}/` : ""}${options.ref.name}`;
93
+ }
94
+
95
+ export function deleteStatusMessage(targets: ResourceRef[], prefix: string): string {
96
+ if (targets.length === 0) {
97
+ return `${prefix} no selection`;
98
+ }
99
+
100
+ if (targets.length === 1) {
101
+ return `${prefix} ${statusLine({ ref: targets[0] })}`;
102
+ }
103
+
104
+ return `${prefix} ${targets.length} resources`;
105
+ }
106
+
107
+ export function resourceSelectionHint(selectedCount: number): string {
108
+ return selectedCount > 0 ? `/ filter Space select d delete ${selectedCount}` : "/ filter Space select";
109
+ }
110
+
111
+ export function resourceEmptyState(status: LoadStatus, filter: string): string {
112
+ if (status === "loading") {
113
+ return "Loading resources...";
114
+ }
115
+
116
+ return filter ? `No resources match "${filter}"` : "No resources loaded";
117
+ }
118
+
119
+ /** Bug-fix: uses revealedText (was concealedText) to show the real value when revealed. */
120
+ export function resourceDetailText(
121
+ line: string | ResourceDetailLine,
122
+ revealedIds: string[],
123
+ ): ResourceDetailTextResult {
124
+ if (typeof line === "string") {
125
+ return { id: line, text: line, revealable: false };
126
+ }
127
+
128
+ if (!line.revealable || !line.revealedText) {
129
+ return { id: line.id, text: line.text, revealable: false };
130
+ }
131
+
132
+ return {
133
+ id: line.id,
134
+ text: revealedIds.includes(line.id) ? line.revealedText : `${line.text} [click to reveal]`,
135
+ revealable: true,
136
+ };
137
+ }
138
+
139
+ export function activePortForwardRoute(portForward: ActivePortForward): string {
140
+ return `localhost:${portForward.localPort} -> ${portForward.remotePort}`;
141
+ }
142
+
143
+ export function activePortForwardLabel(portForward: ActivePortForward): string {
144
+ return `${statusLine({ ref: portForward.ref })} ${activePortForwardRoute(portForward)}`;
145
+ }
146
+
147
+ export function portForwardId(options: PortForwardIdOptions): string {
148
+ return `${options.context}:${options.namespace}:${options.ref.kind}:${options.ref.name}:${options.localPort}:${options.remotePort}`;
149
+ }
150
+
151
+ export function parseLocalPort(value: string): number | undefined {
152
+ if (!/^\d+$/.test(value.trim())) {
153
+ return undefined;
154
+ }
155
+
156
+ const parsed = Number.parseInt(value.trim(), 10);
157
+ return parsed > 0 && parsed <= 65_535 ? parsed : undefined;
158
+ }
159
+
160
+ export function statusTone(status: string): Tone {
161
+ const normalized = status.toLowerCase();
162
+
163
+ if (
164
+ ["fail", "error", "backoff", "crash", "evicted", "terminated", "stopped", "notready"].some((value) =>
165
+ normalized.includes(value),
166
+ )
167
+ ) {
168
+ return "danger";
169
+ }
170
+
171
+ if (["pending", "init", "terminating", "warning", "pressure"].some((value) => normalized.includes(value))) {
172
+ return "warning";
173
+ }
174
+
175
+ if (["running", "ready", "complete", "succeeded", "available"].some((value) => normalized.includes(value))) {
176
+ return "success";
177
+ }
178
+
179
+ return "info";
180
+ }
181
+
182
+ export function kindDescription(kind: ResourceKind): string {
183
+ const scope = kind.namespaced ? "Namespaced" : "Cluster";
184
+ const shortNames = kind.shortNames.length > 0 ? ` ${kind.shortNames.join(",")}` : "";
185
+ return `${scope}${shortNames}`;
186
+ }
187
+
188
+ export function currentKindLabel(options: CurrentKindLabelOptions): string {
189
+ return options.resourceKinds.find((kind) => kind.name === options.selectedKind)?.name ?? options.selectedKind;
190
+ }
191
+
192
+ export function resourcePreview(options: ResourcePreviewOptions): string[] {
193
+ if (!options.resource) {
194
+ return ["Select a resource to inspect it."];
195
+ }
196
+
197
+ return [
198
+ `kind: ${options.resource.ref.kind}`,
199
+ `name: ${options.resource.ref.name}`,
200
+ `namespace: ${options.resource.ref.namespace ?? "cluster"}`,
201
+ `status: ${options.resource.status}`,
202
+ `age: ${options.resource.age}`,
203
+ options.resource.summary ? `summary: ${options.resource.summary}` : "summary: -",
204
+ ];
205
+ }
206
+
207
+ export function normalizeFilter(value: string): string {
208
+ return value.trim().toLowerCase();
209
+ }
210
+
211
+ export function filterResources(options: FilterResourcesOptions): ResourceListItem[] {
212
+ const query = normalizeFilter(options.filter);
213
+
214
+ if (!query) {
215
+ return options.resources;
216
+ }
217
+
218
+ return options.resources.filter((resource) => {
219
+ const searchBlob = [resource.ref.name, resource.status, resource.summary, resource.ref.namespace, resource.ref.kind]
220
+ .filter(Boolean)
221
+ .join(" ")
222
+ .toLowerCase();
223
+
224
+ return searchBlob.includes(query);
225
+ });
226
+ }
227
+
228
+ export function nextVisibleResourceName(options: NextVisibleResourceNameOptions): string | undefined {
229
+ if (options.resources.length === 0) {
230
+ return undefined;
231
+ }
232
+
233
+ if (options.selectedName && options.resources.some((resource) => resource.ref.name === options.selectedName)) {
234
+ return options.selectedName;
235
+ }
236
+
237
+ return options.resources[0]?.ref.name;
238
+ }
239
+
240
+ /** Returns the best namespace to use after loading. Removes the old openk8s-demo hard-code. */
241
+ export function defaultNamespace(options: DefaultNamespaceOptions): string {
242
+ if (options.namespaces.some((namespace) => namespace.name === options.currentNamespace)) {
243
+ return options.currentNamespace;
244
+ }
245
+
246
+ return options.namespaces[0]?.name ?? DEFAULT_NAMESPACE;
247
+ }
248
+
249
+ export function selectedRef(options: SelectedRefOptions): ResourceRef | undefined {
250
+ if (
251
+ options.detail &&
252
+ options.resource &&
253
+ options.detail.ref.name === options.resource.ref.name &&
254
+ options.detail.ref.kind === options.resource.ref.kind &&
255
+ (options.detail.ref.namespace ?? "") === (options.resource.ref.namespace ?? "")
256
+ ) {
257
+ return options.detail.ref;
258
+ }
259
+
260
+ return options.resource?.ref;
261
+ }
262
+
263
+ export function selectedForwardsForRef(options: SelectedForwardsForRefOptions): ActivePortForward[] {
264
+ if (!options.ref) {
265
+ return [];
266
+ }
267
+
268
+ return options.forwards.filter(
269
+ (forward) =>
270
+ forward.ref.kind === options.ref?.kind &&
271
+ forward.ref.name === options.ref?.name &&
272
+ (forward.ref.namespace ?? "") === (options.ref?.namespace ?? ""),
273
+ );
274
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,8 @@
1
+ import { createCliRenderer } from "@opentui/core";
2
+ import { createRoot } from "@opentui/react";
3
+ import { App } from "./app/app";
4
+
5
+ const renderer = await createCliRenderer({
6
+ useMouse: true,
7
+ });
8
+ createRoot(renderer).render(<App />);
@@ -0,0 +1,42 @@
1
+ export function formatAge(value?: string | undefined): string {
2
+ if (!value) {
3
+ return "-";
4
+ }
5
+
6
+ // Normalize helm-style timestamps: "2024-05-12 10:30:00.123456789 +0000 UTC"
7
+ // into ISO 8601: "2024-05-12T10:30:00.123+00:00"
8
+ // Standard ISO strings (from kubectl) pass through unchanged.
9
+ const normalized = value
10
+ .replace(/ [A-Z]+$/, "") // strip trailing timezone name: " UTC", " EST"
11
+ .replace(/(\.\d{3})\d*/, "$1") // truncate sub-millisecond digits
12
+ .replace(" ", "T") // first space → T (date/time separator)
13
+ .replace(" ", "") // second space (before offset) → removed
14
+ .replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); // +0000 → +00:00
15
+
16
+ const created = new Date(normalized);
17
+
18
+ if (Number.isNaN(created.valueOf())) {
19
+ return "-";
20
+ }
21
+
22
+ const diffMs = Date.now() - created.valueOf();
23
+ const diffMinutes = Math.max(0, Math.floor(diffMs / 60_000));
24
+
25
+ if (diffMinutes < 1) {
26
+ return "now";
27
+ }
28
+
29
+ if (diffMinutes < 60) {
30
+ return `${diffMinutes}m`;
31
+ }
32
+
33
+ const diffHours = Math.floor(diffMinutes / 60);
34
+
35
+ if (diffHours < 24) {
36
+ return `${diffHours}h`;
37
+ }
38
+
39
+ const diffDays = Math.floor(diffHours / 24);
40
+
41
+ return `${diffDays}d`;
42
+ }