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.
- package/README.md +194 -40
- 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,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
|
+
}
|
package/src/app/theme.ts
ADDED
|
@@ -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
|
+
}
|
package/src/app/utils.ts
ADDED
|
@@ -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,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
|
+
}
|