openk8s 1.0.5 → 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/README.md +1 -1
- package/package.json +13 -10
- package/src/app/__tests__/app-state.test.ts +48 -6
- package/src/app/__tests__/utils.test.ts +6 -15
- 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 +40 -83
- 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 +171 -0
- package/src/app/components/overlays/index.ts +4 -1
- package/src/app/components/overlays/logs-dialog.tsx +47 -67
- 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 +30 -21
- 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 +32 -16
- 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 +10 -6
- 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-detail-builder.test.ts +4 -6
- package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
- package/src/lib/k8s/detail-builders/event-builder.ts +18 -12
- 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 +4 -7
- package/src/lib/k8s/resource-parser.ts +17 -2
- package/src/lib/k8s/types.ts +12 -4
- 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 +38 -54
- 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
|
|
|
@@ -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
|
}
|
|
@@ -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,8 +1,8 @@
|
|
|
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
|
-
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 {
|
|
@@ -105,10 +104,18 @@ export interface AppError {
|
|
|
105
104
|
|
|
106
105
|
export type NotificationTone = "info" | "success" | "warning" | "danger";
|
|
107
106
|
|
|
107
|
+
export interface NotificationAction {
|
|
108
|
+
id: string;
|
|
109
|
+
label: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
108
112
|
export interface Notification {
|
|
109
113
|
id: string;
|
|
110
114
|
tone: NotificationTone;
|
|
111
115
|
message: string;
|
|
116
|
+
persistent: boolean;
|
|
117
|
+
createdAt: number;
|
|
118
|
+
actions: NotificationAction[];
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
export interface PodMetricEntry {
|
|
@@ -158,6 +165,7 @@ export interface AppState {
|
|
|
158
165
|
revealedSecretValues: Record<string, string>;
|
|
159
166
|
activePortForwards: ActivePortForward[];
|
|
160
167
|
notifications: Notification[];
|
|
168
|
+
notificationHistory: Notification[];
|
|
161
169
|
podMetrics: Record<string, { cpu: string; memory: string }>;
|
|
162
170
|
nodeMetrics: Record<string, { cpu: string; memory: string; cpuPct: number; memPct: number }>;
|
|
163
171
|
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
|
}
|