openk8s 1.0.4 → 1.1.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 +1 -1
- package/package.json +11 -10
- package/src/app/__tests__/app-state.test.ts +47 -5
- package/src/app/__tests__/utils.test.ts +5 -14
- package/src/app/app-actions.ts +5 -0
- package/src/app/app-state.ts +11 -2
- package/src/app/app.tsx +57 -46
- package/src/app/components/detail-sections.tsx +2 -2
- package/src/app/components/footer.tsx +23 -30
- package/src/app/components/inspector.tsx +28 -43
- package/src/app/components/kind-rows.tsx +1 -1
- package/src/app/components/notification-card.tsx +145 -0
- package/src/app/components/notification-tray.tsx +19 -38
- package/src/app/components/overlays/delete-confirm-overlay.tsx +15 -17
- package/src/app/components/overlays/helm-rollback-overlay.tsx +12 -66
- package/src/app/components/overlays/help-overlay.tsx +173 -0
- package/src/app/components/overlays/index.ts +5 -2
- package/src/app/components/overlays/logs-dialog.tsx +50 -80
- package/src/app/components/overlays/notification-history-overlay.tsx +79 -0
- package/src/app/components/overlays/port-forward-list-overlay.tsx +85 -0
- package/src/app/components/overlays/port-forward-overlay.tsx +16 -56
- package/src/app/components/overlays/scale-dialog.tsx +12 -67
- package/src/app/components/overlays/select-overlay.tsx +5 -14
- package/src/app/components/overlays/shared.tsx +85 -6
- package/src/app/components/resource-rows.tsx +1 -1
- package/src/app/constants.ts +24 -0
- package/src/app/hooks/keyboard/filter-handlers.ts +2 -1
- package/src/app/hooks/keyboard/global-handlers.ts +32 -11
- package/src/app/hooks/keyboard/helm-handlers.ts +14 -9
- package/src/app/hooks/keyboard/keys.ts +18 -0
- package/src/app/hooks/keyboard/logs-handlers.ts +3 -1
- package/src/app/hooks/keyboard/navigation-handlers.ts +5 -4
- package/src/app/hooks/keyboard/overlay-handlers.ts +11 -7
- package/src/app/hooks/keyboard/port-forward-handlers.ts +19 -14
- package/src/app/hooks/keyboard/shell-edit-handlers.ts +45 -17
- package/src/app/hooks/use-app-keyboard.ts +22 -2
- package/src/app/hooks/use-app-side-effects.ts +10 -7
- package/src/app/hooks/use-clipboard.ts +8 -10
- package/src/app/hooks/use-data-fetching.ts +11 -5
- package/src/app/hooks/use-log-stream.ts +8 -4
- package/src/app/hooks/use-notifications.ts +92 -0
- package/src/app/hooks/use-port-forward.ts +19 -4
- package/src/app/persistence.ts +7 -3
- package/src/app/syntax-theme.ts +31 -0
- package/src/app/theme.ts +2 -3
- package/src/app/use-footer-hints.ts +21 -16
- package/src/app/utils.ts +1 -9
- package/src/assets/tree-sitter/yaml/highlights.scm +79 -0
- package/src/assets/tree-sitter/yaml/tree-sitter-yaml.wasm +0 -0
- package/src/index.tsx +22 -2
- package/src/lib/k8s/__tests__/k8s-format.test.ts +17 -0
- package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
- package/src/lib/k8s/detail-builders/event-builder.ts +17 -11
- package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +34 -31
- package/src/lib/k8s/detail-builders/node-builder.ts +22 -11
- package/src/lib/k8s/detail-builders/overview-builder.ts +4 -17
- package/src/lib/k8s/detail-builders/pod-builder.ts +52 -24
- package/src/lib/k8s/detail-builders/rbac-builder.ts +20 -29
- package/src/lib/k8s/k8s-format.ts +14 -9
- package/src/lib/k8s/resource-detail-builder.ts +1 -1
- package/src/lib/k8s/resource-parser.ts +17 -2
- package/src/lib/k8s/types.ts +10 -1
- package/src/lib/kubectl/__tests__/metrics-utils.test.ts +9 -0
- package/src/lib/kubectl/kubectl-helpers.ts +16 -11
- package/src/lib/kubectl/kubectl-service.ts +39 -39
- package/src/lib/kubectl/kubectl-types.ts +10 -1
- package/src/lib/kubectl/metrics-utils.ts +21 -7
- package/src/lib/kubectl/spawn-utils.ts +50 -11
- package/src/app/__tests__/components/inspector-tokens.test.ts +0 -101
- package/src/app/components/inspector-tokens.ts +0 -93
- package/src/app/components/port-forwards-tray.tsx +0 -57
|
@@ -3,35 +3,60 @@ import {
|
|
|
3
3
|
buildEnvLine, formatContainerState, formatEnvFromSource,
|
|
4
4
|
formatProbe, formatValueFrom, formatVolumeSource,
|
|
5
5
|
} from "../resource-parser";
|
|
6
|
+
import { formatAge } from "../k8s-format";
|
|
6
7
|
import type { ResourceDetailSection } from "../types";
|
|
7
8
|
import type { KubernetesResource } from "../resource-parser";
|
|
8
9
|
|
|
10
|
+
interface ContainerInfo {
|
|
11
|
+
record: Record<string, unknown>;
|
|
12
|
+
isInit: boolean;
|
|
13
|
+
statusEntry: Record<string, unknown> | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function collectContainers(spec: Record<string, unknown>, status: Record<string, unknown>): ContainerInfo[] {
|
|
17
|
+
const statusMap = new Map<string, Record<string, unknown>>();
|
|
18
|
+
for (const key of ["containerStatuses", "initContainerStatuses"] as const) {
|
|
19
|
+
for (const entry of asArray(status[key]).map(asRecord)) {
|
|
20
|
+
if (entry && asString(entry.name)) {
|
|
21
|
+
statusMap.set(asString(entry.name) ?? "", entry);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const collect = (field: string, isInit: boolean): ContainerInfo[] =>
|
|
27
|
+
asArray(spec[field])
|
|
28
|
+
.map(asRecord)
|
|
29
|
+
.filter((entry): entry is Record<string, unknown> => entry !== undefined)
|
|
30
|
+
.map((record) => {
|
|
31
|
+
const name = asString(record.name) ?? "";
|
|
32
|
+
return { record, isInit, statusEntry: statusMap.get(name) };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return [...collect("initContainers", true), ...collect("containers", false)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function containerLabel(info: ContainerInfo): string {
|
|
39
|
+
const name = asString(info.record.name) ?? "container";
|
|
40
|
+
return info.isInit ? `${name} (init)` : name;
|
|
41
|
+
}
|
|
42
|
+
|
|
9
43
|
export function buildPodSections(resource: KubernetesResource): ResourceDetailSection[] {
|
|
10
44
|
const spec = resource.spec ?? {};
|
|
11
45
|
const status = resource.status ?? {};
|
|
46
|
+
const containers = collectContainers(spec, status);
|
|
12
47
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
.filter((entry): entry is Record<string, unknown> => Boolean(entry && asString(entry.name)))
|
|
17
|
-
.map((entry) => [asString(entry.name) ?? "", entry]),
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
const containers = asArray(spec.containers)
|
|
21
|
-
.map((entry) => asRecord(entry))
|
|
22
|
-
.filter((entry): entry is Record<string, unknown> => entry !== undefined);
|
|
23
|
-
|
|
24
|
-
const containerLines = containers.map((container) => {
|
|
25
|
-
const name = asString(container.name) ?? "container";
|
|
26
|
-
const statusEntry = containerStatuses.get(name);
|
|
48
|
+
const containerLines = containers.map((info) => {
|
|
49
|
+
const name = containerLabel(info);
|
|
50
|
+
const statusEntry = info.statusEntry;
|
|
27
51
|
const ready = asBoolean(statusEntry?.ready);
|
|
28
52
|
const restartCount = asNumber(statusEntry?.restartCount);
|
|
29
53
|
|
|
30
|
-
return `${name} image ${asString(
|
|
54
|
+
return `${name} image ${asString(info.record.image) ?? "-"} ready ${ready === undefined ? "-" : ready ? "yes" : "no"} restarts ${restartCount ?? 0} state ${formatContainerState(statusEntry?.state)}`;
|
|
31
55
|
});
|
|
32
56
|
|
|
33
|
-
const environmentLines = containers.flatMap((
|
|
34
|
-
const containerName =
|
|
57
|
+
const environmentLines = containers.flatMap((info) => {
|
|
58
|
+
const containerName = containerLabel(info);
|
|
59
|
+
const container = info.record;
|
|
35
60
|
const envLines = asArray(container.env)
|
|
36
61
|
.map((entry) => asRecord(entry))
|
|
37
62
|
.filter((entry): entry is Record<string, unknown> => entry !== undefined)
|
|
@@ -78,8 +103,9 @@ export function buildPodSections(resource: KubernetesResource): ResourceDetailSe
|
|
|
78
103
|
return [...envLines, ...envFromLines];
|
|
79
104
|
});
|
|
80
105
|
|
|
81
|
-
const portLines = containers.flatMap((
|
|
82
|
-
const containerName =
|
|
106
|
+
const portLines = containers.flatMap((info) => {
|
|
107
|
+
const containerName = containerLabel(info);
|
|
108
|
+
const container = info.record;
|
|
83
109
|
|
|
84
110
|
return asArray(container.ports)
|
|
85
111
|
.map((entry) => asRecord(entry))
|
|
@@ -92,8 +118,9 @@ export function buildPodSections(resource: KubernetesResource): ResourceDetailSe
|
|
|
92
118
|
});
|
|
93
119
|
});
|
|
94
120
|
|
|
95
|
-
const mountLines = containers.flatMap((
|
|
96
|
-
const containerName =
|
|
121
|
+
const mountLines = containers.flatMap((info) => {
|
|
122
|
+
const containerName = containerLabel(info);
|
|
123
|
+
const container = info.record;
|
|
97
124
|
|
|
98
125
|
return asArray(container.volumeMounts)
|
|
99
126
|
.map((entry) => asRecord(entry))
|
|
@@ -105,8 +132,9 @@ export function buildPodSections(resource: KubernetesResource): ResourceDetailSe
|
|
|
105
132
|
});
|
|
106
133
|
});
|
|
107
134
|
|
|
108
|
-
const probeLines = containers.map((
|
|
109
|
-
const containerName =
|
|
135
|
+
const probeLines = containers.map((info) => {
|
|
136
|
+
const containerName = containerLabel(info);
|
|
137
|
+
const container = info.record;
|
|
110
138
|
return `${containerName}: readiness ${formatProbe(container.readinessProbe)} | liveness ${formatProbe(container.livenessProbe)} | startup ${formatProbe(container.startupProbe)}`;
|
|
111
139
|
});
|
|
112
140
|
|
|
@@ -116,7 +144,7 @@ export function buildPodSections(resource: KubernetesResource): ResourceDetailSe
|
|
|
116
144
|
.map((entry) => {
|
|
117
145
|
const type = asString(entry.type) ?? "Condition";
|
|
118
146
|
const conditionStatus = asString(entry.status) ?? "Unknown";
|
|
119
|
-
const transition = asString(entry.lastTransitionTime)
|
|
147
|
+
const transition = formatAge(asString(entry.lastTransitionTime));
|
|
120
148
|
return `${type}: ${conditionStatus} transitioned ${transition}`;
|
|
121
149
|
});
|
|
122
150
|
|
|
@@ -1,36 +1,34 @@
|
|
|
1
|
-
import { asArray, asRecord, asString } from "../resource-parser";
|
|
1
|
+
import { asArray, asRecord, asString, recordsOf } from "../resource-parser";
|
|
2
2
|
import type { ResourceDetailSection } from "../types";
|
|
3
3
|
import type { KubernetesResource } from "../resource-parser";
|
|
4
4
|
|
|
5
|
+
function asStringOrDash(value: unknown): string {
|
|
6
|
+
return asString(value) ?? "-";
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
export function buildRoleSections(resource: KubernetesResource): ResourceDetailSection[] {
|
|
6
|
-
const raw = resource
|
|
7
|
-
const rules =
|
|
8
|
-
.map(
|
|
9
|
-
|
|
10
|
-
.map((
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const apiGroups = asArray(r.apiGroups).map((g) => String(g) || '""').join(",");
|
|
14
|
-
return `[${apiGroups}] ${resources}: ${verbs}`;
|
|
15
|
-
});
|
|
10
|
+
const raw = asRecord(resource) ?? {};
|
|
11
|
+
const rules = recordsOf(raw.rules).map((r) => {
|
|
12
|
+
const verbs = asArray(r.verbs).map(asStringOrDash).join(",");
|
|
13
|
+
const resources = asArray(r.resources).map(asStringOrDash).join(",");
|
|
14
|
+
const apiGroups = asArray(r.apiGroups).map((g) => asString(g) ?? '""').join(",");
|
|
15
|
+
return `[${apiGroups}] ${resources}: ${verbs}`;
|
|
16
|
+
});
|
|
16
17
|
|
|
17
18
|
return [{ title: "Rules", lines: rules.length > 0 ? rules : ["No rules"] }];
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export function buildRoleBindingSections(resource: KubernetesResource): ResourceDetailSection[] {
|
|
21
|
-
const raw = resource
|
|
22
|
+
const raw = asRecord(resource) ?? {};
|
|
22
23
|
const roleRef = asRecord(raw.roleRef);
|
|
23
24
|
const refLines = roleRef
|
|
24
25
|
? [`${asString(roleRef.kind) ?? "-"} / ${asString(roleRef.name) ?? "-"}`]
|
|
25
26
|
: ["No roleRef"];
|
|
26
27
|
|
|
27
|
-
const subjects =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const ns = asString(s.namespace);
|
|
32
|
-
return `${asString(s.kind) ?? "-"} ${ns ? `${ns}/` : ""}${asString(s.name) ?? "-"}`;
|
|
33
|
-
});
|
|
28
|
+
const subjects = recordsOf(raw.subjects).map((s) => {
|
|
29
|
+
const ns = asString(s.namespace);
|
|
30
|
+
return `${asString(s.kind) ?? "-"} ${ns ? `${ns}/` : ""}${asString(s.name) ?? "-"}`;
|
|
31
|
+
});
|
|
34
32
|
|
|
35
33
|
return [
|
|
36
34
|
{ title: "RoleRef", lines: refLines },
|
|
@@ -39,16 +37,9 @@ export function buildRoleBindingSections(resource: KubernetesResource): Resource
|
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
export function buildServiceAccountSections(resource: KubernetesResource): ResourceDetailSection[] {
|
|
42
|
-
const raw = resource
|
|
43
|
-
const secrets =
|
|
44
|
-
|
|
45
|
-
.filter((s): s is Record<string, unknown> => s !== undefined)
|
|
46
|
-
.map((s) => asString(s.name) ?? "-");
|
|
47
|
-
|
|
48
|
-
const imagePullSecrets = asArray(raw.imagePullSecrets)
|
|
49
|
-
.map((s) => asRecord(s))
|
|
50
|
-
.filter((s): s is Record<string, unknown> => s !== undefined)
|
|
51
|
-
.map((s) => asString(s.name) ?? "-");
|
|
40
|
+
const raw = asRecord(resource) ?? {};
|
|
41
|
+
const secrets = recordsOf(raw.secrets).map((s) => asString(s.name) ?? "-");
|
|
42
|
+
const imagePullSecrets = recordsOf(raw.imagePullSecrets).map((s) => asString(s.name) ?? "-");
|
|
52
43
|
|
|
53
44
|
return [
|
|
54
45
|
{ title: "Secrets", lines: secrets.length > 0 ? secrets : ["None"] },
|
|
@@ -3,17 +3,22 @@ export function formatAge(value?: string | undefined): string {
|
|
|
3
3
|
return "-";
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
.replace(
|
|
13
|
-
.replace(" ", "")
|
|
6
|
+
// Standard ISO strings (from kubectl) pass through unchanged (sub-ms digits are truncated).
|
|
7
|
+
// Helm-style timestamps ("2024-05-12 10:30:00.123456789 +0000 UTC") are normalized to ISO 8601.
|
|
8
|
+
const isHelmFormat = value.includes(" ");
|
|
9
|
+
const strippedTz = value.replace(/ [A-Z]+$/, ""); // strip trailing timezone name: " UTC", " EST"
|
|
10
|
+
const hasNumericOffset = /[+-]\d{4}$/.test(strippedTz);
|
|
11
|
+
const normalized = strippedTz
|
|
12
|
+
.replace(/(\.\d{3})\d*/, "$1") // truncate sub-millisecond digits
|
|
13
|
+
.replace(" ", "T") // first space → T (date/time separator)
|
|
14
|
+
.replace(" ", "") // second space (before offset, if any) → removed
|
|
14
15
|
.replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); // +0000 → +00:00
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
// Helm timestamps are UTC. If a helm-format value lacked a numeric offset,
|
|
18
|
+
// append Z so it parses as UTC rather than the host's local timezone.
|
|
19
|
+
const iso = isHelmFormat && !hasNumericOffset ? `${normalized}Z` : normalized;
|
|
20
|
+
|
|
21
|
+
const created = new Date(iso);
|
|
17
22
|
|
|
18
23
|
if (Number.isNaN(created.valueOf())) {
|
|
19
24
|
return "-";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResourceDetail, ResourceKind } from "./types";
|
|
2
2
|
import { isPodKind } from "./resource-parser";
|
|
3
|
-
import type { KubernetesResource
|
|
3
|
+
import type { KubernetesResource } from "./resource-parser";
|
|
4
4
|
|
|
5
5
|
export { type KubernetesMetadata, type KubernetesResource } from "./resource-parser";
|
|
6
6
|
|
|
@@ -19,12 +19,17 @@ export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
19
19
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Returns the value if it is a non-empty string, otherwise undefined.
|
|
24
|
+
* Note: an explicitly-empty string is treated as absent — callers that need
|
|
25
|
+
* to preserve empty strings should read the field directly.
|
|
26
|
+
*/
|
|
22
27
|
export function asString(value: unknown): string | undefined {
|
|
23
28
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export function asNumber(value: unknown): number | undefined {
|
|
27
|
-
return typeof value === "number" ? value : undefined;
|
|
32
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export function asBoolean(value: unknown): boolean | undefined {
|
|
@@ -35,6 +40,16 @@ export function asArray(value: unknown): unknown[] {
|
|
|
35
40
|
return Array.isArray(value) ? value : [];
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Returns the records (objects) within an array, dropping non-object entries.
|
|
45
|
+
* Convenience for the common `asArray(x).map(asRecord).filter(isRecord)` pattern.
|
|
46
|
+
*/
|
|
47
|
+
export function recordsOf(value: unknown): Record<string, unknown>[] {
|
|
48
|
+
return asArray(value)
|
|
49
|
+
.map(asRecord)
|
|
50
|
+
.filter((entry): entry is Record<string, unknown> => entry !== undefined);
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
export function summarizeLabels(labels?: Record<string, string> | undefined): string {
|
|
39
54
|
const pairs = Object.entries(labels ?? {});
|
|
40
55
|
|
|
@@ -150,7 +165,7 @@ export function formatContainerState(value: unknown): string {
|
|
|
150
165
|
const state = asRecord(value);
|
|
151
166
|
|
|
152
167
|
if (!state) {
|
|
153
|
-
return "
|
|
168
|
+
return "unknown";
|
|
154
169
|
}
|
|
155
170
|
|
|
156
171
|
if (state.running) {
|
package/src/lib/k8s/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type PaneId = "clusters" | "resources" | "inspector";
|
|
2
2
|
|
|
3
|
-
export type OverlayId = "cluster-switcher" | "namespace-switcher" | "delete-confirm" | "port-forward" | "logs" | "scale" | "container-picker" | "helm-rollback";
|
|
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
5
|
export type InspectorTab = "summary" | "yaml" | "events" | "describe";
|
|
6
6
|
|
|
@@ -105,10 +105,18 @@ export interface AppError {
|
|
|
105
105
|
|
|
106
106
|
export type NotificationTone = "info" | "success" | "warning" | "danger";
|
|
107
107
|
|
|
108
|
+
export interface NotificationAction {
|
|
109
|
+
id: string;
|
|
110
|
+
label: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
108
113
|
export interface Notification {
|
|
109
114
|
id: string;
|
|
110
115
|
tone: NotificationTone;
|
|
111
116
|
message: string;
|
|
117
|
+
persistent: boolean;
|
|
118
|
+
createdAt: number;
|
|
119
|
+
actions: NotificationAction[];
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
export interface PodMetricEntry {
|
|
@@ -158,6 +166,7 @@ export interface AppState {
|
|
|
158
166
|
revealedSecretValues: Record<string, string>;
|
|
159
167
|
activePortForwards: ActivePortForward[];
|
|
160
168
|
notifications: Notification[];
|
|
169
|
+
notificationHistory: Notification[];
|
|
161
170
|
podMetrics: Record<string, { cpu: string; memory: string }>;
|
|
162
171
|
nodeMetrics: Record<string, { cpu: string; memory: string; cpuPct: number; memPct: number }>;
|
|
163
172
|
helmRollbackRevision: string;
|
|
@@ -35,12 +35,21 @@ describe("parseMemBytes", () => {
|
|
|
35
35
|
test("Ki", () => expect(parseMemBytes("512Ki")).toBe(512 * 1024));
|
|
36
36
|
test("Mi", () => expect(parseMemBytes("256Mi")).toBe(256 * 1024 ** 2));
|
|
37
37
|
test("Gi", () => expect(parseMemBytes("2Gi")).toBe(2 * 1024 ** 3));
|
|
38
|
+
test("Ti", () => expect(parseMemBytes("1Ti")).toBe(1024 ** 4));
|
|
39
|
+
test("Pi", () => expect(parseMemBytes("1Pi")).toBe(1024 ** 5));
|
|
40
|
+
test("Ei", () => expect(parseMemBytes("1Ei")).toBe(1024 ** 6));
|
|
38
41
|
test("K (decimal)", () => expect(parseMemBytes("1000K")).toBe(1000 * 1000));
|
|
39
42
|
test("M (decimal)", () => expect(parseMemBytes("10M")).toBe(10 * 1000 ** 2));
|
|
40
43
|
test("G (decimal)", () => expect(parseMemBytes("1G")).toBe(1000 ** 3));
|
|
44
|
+
test("T (decimal)", () => expect(parseMemBytes("1T")).toBe(1000 ** 4));
|
|
45
|
+
test("P (decimal)", () => expect(parseMemBytes("1P")).toBe(1000 ** 5));
|
|
46
|
+
test("E (decimal)", () => expect(parseMemBytes("1E")).toBe(1000 ** 6));
|
|
41
47
|
test("plain bytes", () => expect(parseMemBytes("1024")).toBe(1024));
|
|
42
48
|
test("empty string", () => expect(parseMemBytes("")).toBe(0));
|
|
43
49
|
test("invalid", () => expect(parseMemBytes("abc")).toBe(0));
|
|
50
|
+
test("does not match K before Ki (order-dependent regression guard)", () => {
|
|
51
|
+
expect(parseMemBytes("100Ki")).toBe(100 * 1024);
|
|
52
|
+
});
|
|
44
53
|
});
|
|
45
54
|
|
|
46
55
|
// ── formatCpu ────────────────────────────────────────────────────────────────
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatAge } from "../k8s/k8s-format";
|
|
2
|
+
import { asRecord, asString } from "../k8s/resource-parser";
|
|
2
3
|
import type { ResourceKind, ResourceRef } from "../k8s/types";
|
|
3
4
|
import type { GenericListItem } from "./kubectl-types";
|
|
4
5
|
|
|
@@ -6,6 +7,12 @@ export function normalizeKind(kind: string): string {
|
|
|
6
7
|
return kind.trim().toLowerCase();
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
export const HELM_RELEASE_KIND = "helmreleases";
|
|
11
|
+
|
|
12
|
+
export function isHelmRelease(ref: { kind: string } | undefined): ref is { kind: string } {
|
|
13
|
+
return ref !== undefined && normalizeKind(ref.kind) === HELM_RELEASE_KIND;
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
export function normalizeResourceTarget(ref: ResourceRef): string {
|
|
10
17
|
const kind = normalizeKind(ref.kind);
|
|
11
18
|
return `${kind}/${ref.name}`;
|
|
@@ -77,8 +84,8 @@ export function supportsScale(ref: ResourceRef): boolean {
|
|
|
77
84
|
return ["deployment", "deployments", "statefulset", "statefulsets", "replicaset", "replicasets"].includes(kind);
|
|
78
85
|
}
|
|
79
86
|
|
|
80
|
-
export function parseKubectlError(stderr: string, args: string[]): Error {
|
|
81
|
-
const message = stderr.trim() ||
|
|
87
|
+
export function parseKubectlError(stderr: string, args: string[], command = "kubectl"): Error {
|
|
88
|
+
const message = stderr.trim() || `${command} ${args.join(" ")} failed`;
|
|
82
89
|
return new Error(message);
|
|
83
90
|
}
|
|
84
91
|
|
|
@@ -114,9 +121,7 @@ export function resourceStatus(resource: GenericListItem): string {
|
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
if (kind === "rolebinding" || kind === "clusterrolebinding") {
|
|
117
|
-
const subjects = Array.isArray(
|
|
118
|
-
? ((resource as unknown as Record<string, unknown>).subjects as unknown[])
|
|
119
|
-
: [];
|
|
124
|
+
const subjects = Array.isArray(resource.subjects) ? resource.subjects : [];
|
|
120
125
|
return `${subjects.length} subjects`;
|
|
121
126
|
}
|
|
122
127
|
|
|
@@ -164,9 +169,9 @@ export function resourceSummary(resource: GenericListItem): string {
|
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
if (kind === "node") {
|
|
167
|
-
const nodeInfo = (status.nodeInfo ?? {}
|
|
168
|
-
const kubeletVersion =
|
|
169
|
-
const labels =
|
|
172
|
+
const nodeInfo = asRecord(status.nodeInfo) ?? {};
|
|
173
|
+
const kubeletVersion = asString(nodeInfo.kubeletVersion) ?? "";
|
|
174
|
+
const labels = resource.metadata?.labels ?? {};
|
|
170
175
|
const roles = Object.keys(labels)
|
|
171
176
|
.filter((k) => k.startsWith("node-role.kubernetes.io/"))
|
|
172
177
|
.map((k) => k.replace("node-role.kubernetes.io/", ""))
|
|
@@ -175,14 +180,14 @@ export function resourceSummary(resource: GenericListItem): string {
|
|
|
175
180
|
}
|
|
176
181
|
|
|
177
182
|
if (kind === "role" || kind === "clusterrole") {
|
|
178
|
-
const rules = (resource
|
|
183
|
+
const rules = Array.isArray(resource.rules) ? resource.rules : [];
|
|
179
184
|
return `${rules.length} rules`;
|
|
180
185
|
}
|
|
181
186
|
|
|
182
187
|
if (kind === "rolebinding" || kind === "clusterrolebinding") {
|
|
183
|
-
const roleRef =
|
|
188
|
+
const roleRef = resource.roleRef;
|
|
184
189
|
if (roleRef) {
|
|
185
|
-
return `\u2192 ${roleRef.kind ?? ""}/${roleRef.name ?? ""}`;
|
|
190
|
+
return `\u2192 ${asString(roleRef.kind) ?? ""}/${asString(roleRef.name) ?? ""}`;
|
|
186
191
|
}
|
|
187
192
|
return "";
|
|
188
193
|
}
|
|
@@ -84,6 +84,24 @@ export type {
|
|
|
84
84
|
|
|
85
85
|
const KUBECTL_TIMEOUT_MS = 15_000;
|
|
86
86
|
|
|
87
|
+
interface NamespacedOptions {
|
|
88
|
+
context: string;
|
|
89
|
+
namespace?: string | undefined;
|
|
90
|
+
namespaced?: boolean | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildContextArgs(options: NamespacedOptions): string[] {
|
|
94
|
+
const args = ["--context", options.context];
|
|
95
|
+
if (options.namespaced && options.namespace !== undefined) {
|
|
96
|
+
args.push("-n", options.namespace);
|
|
97
|
+
}
|
|
98
|
+
return args;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildHelmBaseArgs(options: { context: string; namespace: string }): string[] {
|
|
102
|
+
return ["--kube-context", options.context, "-n", options.namespace];
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
export class KubectlService {
|
|
88
106
|
async isAvailable(): Promise<boolean> {
|
|
89
107
|
try {
|
|
@@ -213,13 +231,19 @@ export class KubectlService {
|
|
|
213
231
|
jsonArgs.push("-o", "json");
|
|
214
232
|
yamlArgs.push("-o", "yaml");
|
|
215
233
|
|
|
216
|
-
const [resource, yaml,
|
|
234
|
+
const [resource, yaml, describeResult] = await Promise.allSettled([
|
|
217
235
|
runJson<KubernetesResource>({ command: "kubectl", args: jsonArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
|
|
218
236
|
runText({ command: "kubectl", args: yamlArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
|
|
219
|
-
runText({ command: "kubectl", args: describeArgs, timeoutMs: KUBECTL_TIMEOUT_MS })
|
|
237
|
+
runText({ command: "kubectl", args: describeArgs, timeoutMs: KUBECTL_TIMEOUT_MS }),
|
|
220
238
|
]);
|
|
221
239
|
|
|
222
|
-
|
|
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 : "";
|
|
245
|
+
|
|
246
|
+
return buildResourceDetail({ resourceKind: options.resourceKind, resource: resource.value, yaml: yamlText, describe });
|
|
223
247
|
}
|
|
224
248
|
|
|
225
249
|
async listResourceEvents(options: ListResourceEventsOptions): Promise<EventItem[]> {
|
|
@@ -284,11 +308,7 @@ export class KubectlService {
|
|
|
284
308
|
throw new Error(`Logs are not supported yet for ${options.resourceRef.kind}`);
|
|
285
309
|
}
|
|
286
310
|
|
|
287
|
-
const args =
|
|
288
|
-
|
|
289
|
-
if (options.namespaced) {
|
|
290
|
-
args.push("-n", options.namespace);
|
|
291
|
-
}
|
|
311
|
+
const args = buildContextArgs(options);
|
|
292
312
|
|
|
293
313
|
const tail = options.logOptions?.tail ?? 120;
|
|
294
314
|
const since = options.logOptions?.since;
|
|
@@ -307,7 +327,7 @@ export class KubectlService {
|
|
|
307
327
|
}
|
|
308
328
|
|
|
309
329
|
if (logTarget.includeAllPods) {
|
|
310
|
-
args.push("--all-
|
|
330
|
+
args.push("--all-containers=true");
|
|
311
331
|
}
|
|
312
332
|
|
|
313
333
|
if (options.container) {
|
|
@@ -325,11 +345,7 @@ export class KubectlService {
|
|
|
325
345
|
throw new Error(`Scale is not supported yet for ${options.resourceRef.kind}`);
|
|
326
346
|
}
|
|
327
347
|
|
|
328
|
-
const args =
|
|
329
|
-
|
|
330
|
-
if (options.namespaced) {
|
|
331
|
-
args.push("-n", options.namespace);
|
|
332
|
-
}
|
|
348
|
+
const args = buildContextArgs(options);
|
|
333
349
|
|
|
334
350
|
args.push("scale", normalizeResourceTarget(options.resourceRef), `--replicas=${options.replicas}`);
|
|
335
351
|
|
|
@@ -341,11 +357,7 @@ export class KubectlService {
|
|
|
341
357
|
throw new Error(`Rollout restart is not supported yet for ${options.resourceRef.kind}`);
|
|
342
358
|
}
|
|
343
359
|
|
|
344
|
-
const args =
|
|
345
|
-
|
|
346
|
-
if (options.namespaced) {
|
|
347
|
-
args.push("-n", options.namespace);
|
|
348
|
-
}
|
|
360
|
+
const args = buildContextArgs(options);
|
|
349
361
|
|
|
350
362
|
args.push("rollout", "restart", normalizeResourceTarget(options.resourceRef));
|
|
351
363
|
|
|
@@ -357,11 +369,7 @@ export class KubectlService {
|
|
|
357
369
|
return this.uninstallHelmRelease(options);
|
|
358
370
|
}
|
|
359
371
|
|
|
360
|
-
const args =
|
|
361
|
-
|
|
362
|
-
if (options.namespaced) {
|
|
363
|
-
args.push("-n", options.namespace);
|
|
364
|
-
}
|
|
372
|
+
const args = buildContextArgs(options);
|
|
365
373
|
|
|
366
374
|
args.push("delete", normalizeResourceTarget(options.resourceRef));
|
|
367
375
|
|
|
@@ -369,26 +377,18 @@ export class KubectlService {
|
|
|
369
377
|
}
|
|
370
378
|
|
|
371
379
|
startShell(options: StartShellOptions): ChildProcess {
|
|
372
|
-
const args =
|
|
373
|
-
|
|
374
|
-
if (options.namespaced) {
|
|
375
|
-
args.push("-n", options.namespace);
|
|
376
|
-
}
|
|
380
|
+
const args = buildContextArgs(options);
|
|
377
381
|
|
|
378
382
|
const kind = normalizeKind(options.resourceRef.kind);
|
|
379
383
|
const target = kind === "pod" || kind === "pods" ? `pod/${options.resourceRef.name}` : normalizeResourceTarget(options.resourceRef);
|
|
380
384
|
|
|
381
385
|
args.push("exec", "-it", target, "--", "sh", "-lc", "exec ${SHELL:-/bin/sh}");
|
|
382
386
|
|
|
383
|
-
return startPersistentProcess({ command: "kubectl", args, stdio: "inherit" });
|
|
387
|
+
return startPersistentProcess({ command: "kubectl", args, stdio: ["inherit", "inherit", "pipe"] });
|
|
384
388
|
}
|
|
385
389
|
|
|
386
390
|
editResource(options: EditResourceOptions): ChildProcess {
|
|
387
|
-
const args =
|
|
388
|
-
|
|
389
|
-
if (options.namespaced) {
|
|
390
|
-
args.push("-n", options.namespace);
|
|
391
|
-
}
|
|
391
|
+
const args = buildContextArgs(options);
|
|
392
392
|
|
|
393
393
|
args.push("edit", normalizeResourceTarget(options.resourceRef));
|
|
394
394
|
|
|
@@ -529,7 +529,7 @@ export class KubectlService {
|
|
|
529
529
|
const parsed = await runJson<SecretResponse>({ command: "kubectl", args, timeoutMs: KUBECTL_TIMEOUT_MS });
|
|
530
530
|
const encoded = parsed.data?.[options.key];
|
|
531
531
|
|
|
532
|
-
if (
|
|
532
|
+
if (encoded === undefined) {
|
|
533
533
|
throw new Error(`Key "${options.key}" not found in secret "${options.name}"`);
|
|
534
534
|
}
|
|
535
535
|
|
|
@@ -547,7 +547,7 @@ export class KubectlService {
|
|
|
547
547
|
}
|
|
548
548
|
|
|
549
549
|
private async listHelmReleases(options: ListResourcesOptions): Promise<ResourceListItem[]> {
|
|
550
|
-
const args = ["list",
|
|
550
|
+
const args = ["list", ...buildHelmBaseArgs(options), "-o", "json"];
|
|
551
551
|
const result = await runCommand({ command: "helm", args, timeoutMs: KUBECTL_TIMEOUT_MS });
|
|
552
552
|
const items = JSON.parse(result.stdout) as HelmListItem[];
|
|
553
553
|
|
|
@@ -564,7 +564,7 @@ export class KubectlService {
|
|
|
564
564
|
}
|
|
565
565
|
|
|
566
566
|
private async getHelmReleaseDetail(options: GetResourceDetailOptions): Promise<ResourceDetail> {
|
|
567
|
-
const baseArgs =
|
|
567
|
+
const baseArgs = buildHelmBaseArgs(options);
|
|
568
568
|
|
|
569
569
|
const [statusResult, valuesResult] = await Promise.all([
|
|
570
570
|
runCommand({ command: "helm", args: ["status", options.name, ...baseArgs, "-o", "json"], timeoutMs: KUBECTL_TIMEOUT_MS }),
|
|
@@ -621,7 +621,7 @@ export class KubectlService {
|
|
|
621
621
|
private async uninstallHelmRelease(options: DeleteResourceOptions): Promise<void> {
|
|
622
622
|
await runCommand({
|
|
623
623
|
command: "helm",
|
|
624
|
-
args: ["uninstall", options.resourceRef.name,
|
|
624
|
+
args: ["uninstall", options.resourceRef.name, ...buildHelmBaseArgs(options)],
|
|
625
625
|
timeoutMs: KUBECTL_TIMEOUT_MS,
|
|
626
626
|
});
|
|
627
627
|
}
|
|
@@ -142,14 +142,23 @@ export interface GenericListItem {
|
|
|
142
142
|
name?: string | undefined;
|
|
143
143
|
namespace?: string | undefined;
|
|
144
144
|
creationTimestamp?: string | undefined;
|
|
145
|
+
labels?: Record<string, string> | undefined;
|
|
145
146
|
} | undefined;
|
|
146
147
|
spec?: Record<string, unknown> | undefined;
|
|
147
148
|
status?: Record<string, unknown> | undefined;
|
|
149
|
+
// Event-only fields
|
|
148
150
|
type?: string | undefined;
|
|
149
151
|
reason?: string | undefined;
|
|
150
152
|
message?: string | undefined;
|
|
151
153
|
lastTimestamp?: string | undefined;
|
|
152
154
|
firstTimestamp?: string | undefined;
|
|
155
|
+
// RBAC fields
|
|
156
|
+
rules?: unknown[] | undefined;
|
|
157
|
+
roleRef?: Record<string, unknown> | undefined;
|
|
158
|
+
subjects?: unknown[] | undefined;
|
|
159
|
+
// ServiceAccount fields
|
|
160
|
+
secrets?: unknown[] | undefined;
|
|
161
|
+
imagePullSecrets?: unknown[] | undefined;
|
|
153
162
|
}
|
|
154
163
|
|
|
155
164
|
export interface GenericListResponse {
|
|
@@ -218,7 +227,7 @@ export interface HelmStatusResponse {
|
|
|
218
227
|
}
|
|
219
228
|
|
|
220
229
|
export interface ApiResourcesResponse {
|
|
221
|
-
resources
|
|
230
|
+
resources?: ApiResourceItem[] | undefined;
|
|
222
231
|
}
|
|
223
232
|
|
|
224
233
|
export interface ApiResourceItem {
|
|
@@ -8,16 +8,30 @@ export function parseCpuNano(value: string): number {
|
|
|
8
8
|
|
|
9
9
|
export function parseMemBytes(value: string): number {
|
|
10
10
|
if (!value) return 0;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
// Order matters: multi-character (binary) suffixes must be checked before their
|
|
12
|
+
// single-letter decimal counterparts (e.g. "Ki" before "K").
|
|
13
|
+
const units: Array<[string, number]> = [
|
|
14
|
+
["Ei", 1024 ** 6],
|
|
15
|
+
["Pi", 1024 ** 5],
|
|
16
|
+
["Ti", 1024 ** 4],
|
|
17
|
+
["Gi", 1024 ** 3],
|
|
18
|
+
["Mi", 1024 ** 2],
|
|
19
|
+
["Ki", 1024],
|
|
20
|
+
["E", 1000 ** 6],
|
|
21
|
+
["P", 1000 ** 5],
|
|
22
|
+
["T", 1000 ** 4],
|
|
23
|
+
["G", 1000 ** 3],
|
|
24
|
+
["M", 1000 ** 2],
|
|
25
|
+
["K", 1000],
|
|
26
|
+
];
|
|
27
|
+
for (const [suffix, mult] of units) {
|
|
16
28
|
if (value.endsWith(suffix)) {
|
|
17
|
-
|
|
29
|
+
const n = Number.parseFloat(value);
|
|
30
|
+
return Number.isFinite(n) ? n * mult : 0;
|
|
18
31
|
}
|
|
19
32
|
}
|
|
20
|
-
|
|
33
|
+
const n = Number.parseFloat(value);
|
|
34
|
+
return Number.isFinite(n) ? n : 0;
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
export function formatCpu(nano: number): string {
|