openk8s 1.1.0 → 1.2.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/package.json +3 -1
- package/src/app/__tests__/app-state.test.ts +1 -1
- package/src/app/__tests__/utils.test.ts +1 -1
- package/src/app/components/inspector.tsx +17 -45
- package/src/app/components/overlays/help-overlay.tsx +1 -3
- package/src/app/hooks/keyboard/global-handlers.ts +0 -12
- package/src/app/hooks/keyboard/shell-edit-handlers.ts +1 -1
- package/src/app/hooks/use-data-fetching.ts +2 -2
- package/src/lib/k8s/__tests__/resource-detail-builder.test.ts +4 -6
- package/src/lib/k8s/detail-builders/event-builder.ts +1 -1
- package/src/lib/k8s/resource-detail-builder.ts +3 -6
- package/src/lib/k8s/types.ts +2 -3
- package/src/lib/kubectl/kubectl-service.ts +9 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openk8s",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "A terminal UI for Kubernetes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@opentui/core": "^0.4.1",
|
|
57
57
|
"@opentui/react": "^0.4.1",
|
|
58
|
+
"@types/js-yaml": "^4.0.9",
|
|
59
|
+
"js-yaml": "^5.1.0",
|
|
58
60
|
"react": "^19.2.7"
|
|
59
61
|
}
|
|
60
62
|
}
|
|
@@ -174,7 +174,7 @@ describe("reducer", () => {
|
|
|
174
174
|
|
|
175
175
|
describe("setSelectedResourceDetail", () => {
|
|
176
176
|
test("sets detail", () => {
|
|
177
|
-
const detail: any = { ref: { kind: "Pod", name: "test" },
|
|
177
|
+
const detail: any = { ref: { kind: "Pod", name: "test" }, summarySections: [], yaml: "" };
|
|
178
178
|
const state = reduce({ type: "setSelectedResourceDetail", detail });
|
|
179
179
|
expect(state.selectedResourceDetail).toBe(detail);
|
|
180
180
|
});
|
|
@@ -298,7 +298,7 @@ describe("defaultNamespace", () => {
|
|
|
298
298
|
describe("selectedRef", () => {
|
|
299
299
|
const detail: ResourceDetail = {
|
|
300
300
|
ref: { kind: "Pod", name: "web", namespace: "default" },
|
|
301
|
-
|
|
301
|
+
summarySections: [], yaml: "",
|
|
302
302
|
};
|
|
303
303
|
|
|
304
304
|
test("returns detail ref when it matches resource", () => {
|
|
@@ -16,8 +16,6 @@ import type { EventItem, InspectorTab, PaneId, ResourceDetail } from "../../lib/
|
|
|
16
16
|
export const INSPECTOR_TABS: Array<{ id: InspectorTab; label: string; hotkey: string }> = [
|
|
17
17
|
{ id: "summary", label: "Summary", hotkey: "s" },
|
|
18
18
|
{ id: "yaml", label: "YAML", hotkey: "y" },
|
|
19
|
-
{ id: "events", label: "Events", hotkey: "v" },
|
|
20
|
-
{ id: "describe", label: "Describe", hotkey: "i" },
|
|
21
19
|
];
|
|
22
20
|
|
|
23
21
|
export interface InspectorTabsProps {
|
|
@@ -132,54 +130,28 @@ export function InspectorBody({
|
|
|
132
130
|
);
|
|
133
131
|
}
|
|
134
132
|
|
|
135
|
-
if (activeTab === "describe") {
|
|
136
|
-
return (
|
|
137
|
-
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
138
|
-
<box style={{ height: 1 }} />
|
|
139
|
-
<scrollbox
|
|
140
|
-
focused={active}
|
|
141
|
-
scrollX
|
|
142
|
-
horizontalScrollbarOptions={{ showArrows: false }}
|
|
143
|
-
onMouseDown={onActivate}
|
|
144
|
-
flexGrow={1}
|
|
145
|
-
>
|
|
146
|
-
<box style={{ flexDirection: "column", paddingRight: 1 }}>
|
|
147
|
-
{detail?.describe ? (
|
|
148
|
-
detail.describe.split("\n").map((line, index) => (
|
|
149
|
-
<text key={`${index}:${line}`} fg={TEXT_SUBTLE} wrapMode="none">
|
|
150
|
-
{line || " "}
|
|
151
|
-
</text>
|
|
152
|
-
))
|
|
153
|
-
) : detailStatus === "loading" ? (
|
|
154
|
-
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
155
|
-
Loading describe output...
|
|
156
|
-
</text>
|
|
157
|
-
) : (
|
|
158
|
-
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
159
|
-
No describe output available
|
|
160
|
-
</text>
|
|
161
|
-
)}
|
|
162
|
-
</box>
|
|
163
|
-
</scrollbox>
|
|
164
|
-
</box>
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
133
|
return (
|
|
169
134
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
170
135
|
<box style={{ height: 1 }} />
|
|
171
136
|
<scrollbox focused={active} onMouseDown={onActivate} flexGrow={1}>
|
|
172
137
|
<box style={{ flexDirection: "column", width: "100%", paddingRight: 1 }}>
|
|
173
|
-
{
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
138
|
+
{detail ? (
|
|
139
|
+
<>
|
|
140
|
+
<DetailSectionsInternal
|
|
141
|
+
sections={detail.summarySections}
|
|
142
|
+
revealedIds={revealedDetailLineIds}
|
|
143
|
+
revealedSecretValues={revealedSecretValues}
|
|
144
|
+
onToggleReveal={onToggleReveal}
|
|
145
|
+
onCopyText={onCopyText}
|
|
146
|
+
/>
|
|
147
|
+
<box style={{ flexDirection: "column", marginTop: 1, marginBottom: 1 }}>
|
|
148
|
+
<text fg={KEY_HINT}>
|
|
149
|
+
{"─ "}
|
|
150
|
+
<span fg={TEXT_PRIMARY} attributes={TextAttributes.BOLD}>Recent Events</span>
|
|
151
|
+
</text>
|
|
152
|
+
<EventList events={events} status={eventsStatus} />
|
|
153
|
+
</box>
|
|
154
|
+
</>
|
|
183
155
|
) : detailStatus === "loading" ? (
|
|
184
156
|
<text fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>
|
|
185
157
|
Loading resource detail...
|
|
@@ -23,10 +23,8 @@ const LEFT_SECTIONS: HelpSection[] = [
|
|
|
23
23
|
{
|
|
24
24
|
title: "Inspector Tabs",
|
|
25
25
|
rows: [
|
|
26
|
-
["S", "summary tab"],
|
|
26
|
+
["S", "summary tab (incl. recent events)"],
|
|
27
27
|
["Y", "yaml tab (press again to copy)"],
|
|
28
|
-
["V", "events tab"],
|
|
29
|
-
["I", "describe tab"],
|
|
30
28
|
],
|
|
31
29
|
},
|
|
32
30
|
{
|
|
@@ -73,18 +73,6 @@ export function handleGlobalHotkeys(
|
|
|
73
73
|
return true;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// V → events tab
|
|
77
|
-
if (key.name === "v" && !key.shift) {
|
|
78
|
-
dispatch({ type: "setInspectorTab", tab: "events" });
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// I → describe tab
|
|
83
|
-
if (key.name === "i" && !key.shift) {
|
|
84
|
-
dispatch({ type: "setInspectorTab", tab: "describe" });
|
|
85
|
-
return true;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
76
|
// L → logs
|
|
89
77
|
if (key.name === "l" && !key.shift) {
|
|
90
78
|
if (activeResourceRef) {
|
|
@@ -90,7 +90,7 @@ export function handleEditKeys(
|
|
|
90
90
|
|
|
91
91
|
if (isHelm) {
|
|
92
92
|
const chartName = activeDetail?.helmChart ?? "";
|
|
93
|
-
const values = activeDetail?.
|
|
93
|
+
const values = activeDetail?.helmValues ?? "";
|
|
94
94
|
const tmpPath = `/tmp/openk8s-${target.name}-${Date.now()}.yaml`;
|
|
95
95
|
const cleanupTmp = (): void => { try { unlinkSync(tmpPath); } catch { /* ignore */ } };
|
|
96
96
|
|
|
@@ -289,7 +289,7 @@ export function useDataFetching(
|
|
|
289
289
|
useEffect(() => {
|
|
290
290
|
const context = state.activeContext;
|
|
291
291
|
|
|
292
|
-
if (!context || !activeResourceRef || !selectedKind
|
|
292
|
+
if (!context || !activeResourceRef || !selectedKind) {
|
|
293
293
|
return;
|
|
294
294
|
}
|
|
295
295
|
|
|
@@ -333,7 +333,7 @@ export function useDataFetching(
|
|
|
333
333
|
return () => {
|
|
334
334
|
cancelled = true;
|
|
335
335
|
};
|
|
336
|
-
}, [activeResourceRef, selectedKind, state.activeContext, state.activeNamespace,
|
|
336
|
+
}, [activeResourceRef, selectedKind, state.activeContext, state.activeNamespace, viewPollTick, manualRefreshNonce]);
|
|
337
337
|
|
|
338
338
|
// Poll pod metrics when viewing pods (silent degradation on error)
|
|
339
339
|
useEffect(() => {
|
|
@@ -12,13 +12,11 @@ describe("buildResourceDetail", () => {
|
|
|
12
12
|
resourceKind: defaultKind,
|
|
13
13
|
resource: { apiVersion: "v1", kind: "Pod", metadata },
|
|
14
14
|
yaml: "apiVersion: v1\nkind: Pod",
|
|
15
|
-
describe: "Name: test-pod",
|
|
16
15
|
});
|
|
17
16
|
|
|
18
17
|
expect(detail.ref.kind).toBe("Pod");
|
|
19
18
|
expect(detail.ref.name).toBe("test-pod");
|
|
20
19
|
expect(detail.yaml).toContain("apiVersion: v1");
|
|
21
|
-
expect(detail.describe).toContain("Name: test-pod");
|
|
22
20
|
expect(detail.summarySections[0]?.title).toBe("Overview");
|
|
23
21
|
});
|
|
24
22
|
|
|
@@ -58,8 +56,8 @@ describe("buildResourceDetail", () => {
|
|
|
58
56
|
});
|
|
59
57
|
|
|
60
58
|
expect(detail.summarySections.some((s) => s.title === "HPA")).toBe(true);
|
|
61
|
-
expect(detail.
|
|
62
|
-
expect(detail.
|
|
59
|
+
expect(detail.summarySections.some((s) => s.lines.some((l) => typeof l === "string" && l.includes("minReplicas: 1")))).toBe(true);
|
|
60
|
+
expect(detail.summarySections.some((s) => s.lines.some((l) => typeof l === "string" && l.includes("maxReplicas: 10")))).toBe(true);
|
|
63
61
|
});
|
|
64
62
|
|
|
65
63
|
test("builds CronJob sections", () => {
|
|
@@ -76,7 +74,7 @@ describe("buildResourceDetail", () => {
|
|
|
76
74
|
});
|
|
77
75
|
|
|
78
76
|
expect(detail.summarySections.some((s) => s.title === "CronJob")).toBe(true);
|
|
79
|
-
expect(detail.
|
|
77
|
+
expect(detail.summarySections.some((s) => s.lines.some((l) => typeof l === "string" && l.includes("schedule")))).toBe(true);
|
|
80
78
|
});
|
|
81
79
|
|
|
82
80
|
test("builds Node sections", () => {
|
|
@@ -116,7 +114,7 @@ describe("buildResourceDetail", () => {
|
|
|
116
114
|
});
|
|
117
115
|
|
|
118
116
|
expect(detail.summarySections.some((s) => s.title === "Event")).toBe(true);
|
|
119
|
-
expect(detail.
|
|
117
|
+
expect(detail.summarySections.some((s) => s.lines.some((l) => typeof l === "string" && l.includes("BackOff")))).toBe(true);
|
|
120
118
|
});
|
|
121
119
|
|
|
122
120
|
test("builds Role sections", () => {
|
|
@@ -7,7 +7,7 @@ function asStringOrDash(value: unknown): string {
|
|
|
7
7
|
return asString(value) ?? "-";
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export function
|
|
10
|
+
export function buildEventObjectSections(resource: KubernetesResource): ResourceDetailSection[] {
|
|
11
11
|
const raw = asRecord(resource) ?? {};
|
|
12
12
|
const involvedObject = asRecord(raw.involvedObject) ?? {};
|
|
13
13
|
const source = asRecord(raw.source) ?? {};
|
|
@@ -8,9 +8,9 @@ import { buildOverviewSection, buildPortForward } from "./detail-builders/overvi
|
|
|
8
8
|
import { buildPodSections } from "./detail-builders/pod-builder";
|
|
9
9
|
import { buildHpaSections, buildCronJobSections } from "./detail-builders/hpa-cronjob-builder";
|
|
10
10
|
import { buildNodeSections } from "./detail-builders/node-builder";
|
|
11
|
-
import {
|
|
11
|
+
import { buildEventObjectSections } from "./detail-builders/event-builder";
|
|
12
12
|
import { buildRoleSections, buildRoleBindingSections, buildServiceAccountSections } from "./detail-builders/rbac-builder";
|
|
13
|
-
import {
|
|
13
|
+
import { extractContainerNames } from "./resource-parser";
|
|
14
14
|
|
|
15
15
|
export function buildResourceDetail(options: BuildResourceDetailOptions): ResourceDetail {
|
|
16
16
|
const kindName = options.resource.kind ?? options.resourceKind?.name ?? "unknown";
|
|
@@ -28,7 +28,7 @@ export function buildResourceDetail(options: BuildResourceDetailOptions): Resour
|
|
|
28
28
|
} else if (normalizedKind === "node") {
|
|
29
29
|
additionalSections = buildNodeSections(options.resource);
|
|
30
30
|
} else if (normalizedKind === "event" || normalizedKind === "events") {
|
|
31
|
-
additionalSections =
|
|
31
|
+
additionalSections = buildEventObjectSections(options.resource);
|
|
32
32
|
} else if (normalizedKind === "role" || normalizedKind === "clusterrole") {
|
|
33
33
|
additionalSections = buildRoleSections(options.resource);
|
|
34
34
|
} else if (normalizedKind === "rolebinding" || normalizedKind === "clusterrolebinding") {
|
|
@@ -47,10 +47,8 @@ export function buildResourceDetail(options: BuildResourceDetailOptions): Resour
|
|
|
47
47
|
name: options.resource.metadata?.name ?? "unknown",
|
|
48
48
|
namespace: options.resource.metadata?.namespace,
|
|
49
49
|
},
|
|
50
|
-
summaryLines: flattenSections(summarySections),
|
|
51
50
|
summarySections,
|
|
52
51
|
yaml: options.yaml,
|
|
53
|
-
describe: options.describe,
|
|
54
52
|
replicas: overview.replicas,
|
|
55
53
|
portForwards: portForward.length > 0 ? portForward : undefined,
|
|
56
54
|
containers: containers && containers.length > 0 ? containers : undefined,
|
|
@@ -61,5 +59,4 @@ export interface BuildResourceDetailOptions {
|
|
|
61
59
|
resourceKind?: ResourceKind | undefined;
|
|
62
60
|
resource: KubernetesResource;
|
|
63
61
|
yaml: string;
|
|
64
|
-
describe?: string | undefined;
|
|
65
62
|
}
|
package/src/lib/k8s/types.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type PaneId = "clusters" | "resources" | "inspector";
|
|
|
2
2
|
|
|
3
3
|
export type OverlayId = "cluster-switcher" | "namespace-switcher" | "delete-confirm" | "port-forward" | "port-forward-list" | "logs" | "scale" | "container-picker" | "helm-rollback" | "notification-history" | "help";
|
|
4
4
|
|
|
5
|
-
export type InspectorTab = "summary" | "yaml"
|
|
5
|
+
export type InspectorTab = "summary" | "yaml";
|
|
6
6
|
|
|
7
7
|
export type LoadStatus = "idle" | "loading" | "ready" | "error";
|
|
8
8
|
|
|
@@ -68,14 +68,13 @@ export interface ResourceDetailSection {
|
|
|
68
68
|
|
|
69
69
|
export interface ResourceDetail {
|
|
70
70
|
ref: ResourceRef;
|
|
71
|
-
summaryLines: string[];
|
|
72
71
|
summarySections: ResourceDetailSection[];
|
|
73
72
|
yaml: string;
|
|
74
|
-
describe?: string | undefined;
|
|
75
73
|
replicas?: number | undefined;
|
|
76
74
|
portForwards?: ResourcePortForward[] | undefined;
|
|
77
75
|
containers?: string[] | undefined;
|
|
78
76
|
helmChart?: string | undefined;
|
|
77
|
+
helmValues?: string | undefined;
|
|
79
78
|
}
|
|
80
79
|
|
|
81
80
|
export interface EventItem {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ChildProcess } from "node:child_process";
|
|
2
2
|
|
|
3
|
+
import { dump as dumpYaml } from "js-yaml";
|
|
3
4
|
import { formatAge } from "../k8s/k8s-format";
|
|
4
5
|
import { buildResourceDetail, type KubernetesResource } from "../k8s/resource-detail-builder";
|
|
5
6
|
import type {
|
|
@@ -219,31 +220,17 @@ export class KubectlService {
|
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
const jsonArgs = ["--context", options.context, "get", options.resourceKind.name, options.name];
|
|
222
|
-
const yamlArgs = ["--context", options.context, "get", options.resourceKind.name, options.name];
|
|
223
|
-
const describeArgs = ["--context", options.context, "describe", options.resourceKind.name, options.name];
|
|
224
223
|
|
|
225
224
|
if (options.resourceKind.namespaced) {
|
|
226
225
|
jsonArgs.push("-n", options.namespace);
|
|
227
|
-
yamlArgs.push("-n", options.namespace);
|
|
228
|
-
describeArgs.push("-n", options.namespace);
|
|
229
226
|
}
|
|
230
227
|
|
|
231
228
|
jsonArgs.push("-o", "json");
|
|
232
|
-
yamlArgs.push("-o", "yaml");
|
|
233
229
|
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
runText({ command: "kubectl", args: yamlArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
|
|
237
|
-
runText({ command: "kubectl", args: describeArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
|
|
238
|
-
]);
|
|
239
|
-
|
|
240
|
-
if (resource.status !== "fulfilled") {
|
|
241
|
-
throw resource.reason;
|
|
242
|
-
}
|
|
243
|
-
const yamlText = yaml.status === "fulfilled" ? yaml.value : "";
|
|
244
|
-
const describe = describeResult.status === "fulfilled" ? describeResult.value : "";
|
|
230
|
+
const resource = await runJson<KubernetesResource>({ command: "kubectl", args: jsonArgs, timeoutMs: KUBECTL_TIMEOUT_MS });
|
|
231
|
+
const yamlText = dumpYaml(resource, { lineWidth: -1, noRefs: true });
|
|
245
232
|
|
|
246
|
-
return buildResourceDetail({ resourceKind: options.resourceKind, resource
|
|
233
|
+
return buildResourceDetail({ resourceKind: options.resourceKind, resource, yaml: yamlText });
|
|
247
234
|
}
|
|
248
235
|
|
|
249
236
|
async listResourceEvents(options: ListResourceEventsOptions): Promise<EventItem[]> {
|
|
@@ -566,13 +553,15 @@ export class KubectlService {
|
|
|
566
553
|
private async getHelmReleaseDetail(options: GetResourceDetailOptions): Promise<ResourceDetail> {
|
|
567
554
|
const baseArgs = buildHelmBaseArgs(options);
|
|
568
555
|
|
|
569
|
-
const [statusResult, valuesResult] = await Promise.all([
|
|
556
|
+
const [statusResult, valuesResult, manifestResult] = await Promise.all([
|
|
570
557
|
runCommand({ command: "helm", args: ["status", options.name, ...baseArgs, "-o", "json"], timeoutMs: KUBECTL_TIMEOUT_MS }),
|
|
571
558
|
runCommand({ command: "helm", args: ["get", "values", options.name, ...baseArgs], timeoutMs: KUBECTL_TIMEOUT_MS }),
|
|
559
|
+
runCommand({ command: "helm", args: ["get", "manifest", options.name, ...baseArgs], timeoutMs: KUBECTL_TIMEOUT_MS }),
|
|
572
560
|
]);
|
|
573
561
|
|
|
574
562
|
const status = JSON.parse(statusResult.stdout) as HelmStatusResponse;
|
|
575
|
-
const
|
|
563
|
+
const helmValues = valuesResult.stdout;
|
|
564
|
+
const yaml = manifestResult.stdout;
|
|
576
565
|
|
|
577
566
|
const overviewLines: string[] = [
|
|
578
567
|
`name: ${status.name}`,
|
|
@@ -598,23 +587,18 @@ export class KubectlService {
|
|
|
598
587
|
...(notesLines.length > 0 ? [{ title: "Notes", lines: notesLines }] : []),
|
|
599
588
|
];
|
|
600
589
|
|
|
601
|
-
const summaryLines = summarySections.flatMap((section) => [
|
|
602
|
-
section.title,
|
|
603
|
-
...section.lines.map((line) => ` ${line}`),
|
|
604
|
-
]);
|
|
605
|
-
|
|
606
590
|
return {
|
|
607
591
|
ref: {
|
|
608
592
|
kind: "helmreleases",
|
|
609
593
|
name: status.name,
|
|
610
594
|
namespace: status.namespace,
|
|
611
595
|
},
|
|
612
|
-
summaryLines,
|
|
613
596
|
summarySections,
|
|
614
597
|
yaml,
|
|
615
598
|
replicas: undefined,
|
|
616
599
|
portForwards: undefined,
|
|
617
600
|
helmChart: status.chart.metadata.name,
|
|
601
|
+
helmValues,
|
|
618
602
|
};
|
|
619
603
|
}
|
|
620
604
|
|