Files
kuber/lib/graph.ts
T
2026-09-27 10:40:50 +00:00

499 lines
16 KiB
TypeScript

import type { KubernetesObject } from "@kubernetes/client-node";
type GraphObject = KubernetesObject & {
spec?: Record<string, unknown>;
status?: Record<string, unknown>;
};
type Edge = {
from: string;
to: string;
type: "owner" | "hpa" | "service" | "ingress" | "volume";
};
export type NodeStatus = {
label: string;
level: "good" | "warn" | "bad";
};
export type GraphNode = {
id: string;
status: NodeStatus;
children: GraphNode[];
};
export type NamespaceGraph = {
namespace: string;
roots: GraphNode[];
};
const resources = [
["apps/v1", "Deployment"],
["apps/v1", "StatefulSet"],
["apps/v1", "DaemonSet"],
["apps/v1", "ReplicaSet"],
["v1", "Pod"],
["v1", "Service"],
["networking.k8s.io/v1", "Ingress"],
["autoscaling/v2", "HorizontalPodAutoscaler"],
["v1", "PersistentVolumeClaim"],
["v1", "ConfigMap"],
["v1", "Secret"],
["batch/v1", "Job"],
["batch/v1", "CronJob"],
["policy/v1", "PodDisruptionBudget"],
] as const;
const ansi = {
reset: "\u001b[0m",
fgBlack: "\u001b[30m",
fgBlue: "\u001b[34m",
fgPurple: "\u001b[35m",
fgYellow: "\u001b[33m",
fgGreen: "\u001b[32m",
bgGreen: "\u001b[42m",
bgYellow: "\u001b[43m",
bgRed: "\u001b[41m",
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getKind(object: GraphObject): string {
return object.kind ?? "Unknown";
}
function getName(object: GraphObject): string {
return object.metadata?.name ?? "unknown";
}
function getId(object: GraphObject): string {
return `${getKind(object)}/${getName(object)}`;
}
function getNamespace(object: GraphObject): string {
return object.metadata?.namespace ?? "default";
}
function number(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}
function hasCondition(
object: GraphObject,
type: string,
status: string,
): boolean {
const conditions = Array.isArray(object.status?.conditions)
? object.status.conditions
: [];
return conditions.some(
(condition) =>
isRecord(condition) &&
condition.type === type &&
condition.status === status,
);
}
function stringRecord(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
const entries = Object.entries(value);
if (entries.some(([, item]) => typeof item !== "string")) return undefined;
return Object.fromEntries(entries) as Record<string, string>;
}
function matchesSelector(
selector: Record<string, string> | undefined,
labels: Record<string, string> | undefined,
): boolean {
const entries = Object.entries(selector ?? {});
return (
entries.length > 0 &&
entries.every(([key, value]) => labels?.[key] === value)
);
}
function priority(type: Edge["type"]): number {
return { owner: 5, hpa: 4, ingress: 3, service: 2, volume: 1 }[type];
}
function pushEdge(edges: Edge[], seen: Set<string>, edge: Edge): void {
const key = `${edge.from}\0${edge.to}\0${edge.type}`;
if (seen.has(key)) return;
seen.add(key);
edges.push(edge);
}
function collectEdges(items: GraphObject[]): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const pods = items.filter((item) => getKind(item) === "Pod");
for (const child of items) {
for (const owner of child.metadata?.ownerReferences ?? []) {
if (owner.kind && owner.name) {
pushEdge(edges, seen, {
from: `${owner.kind}/${owner.name}`,
to: getId(child),
type: "owner",
});
}
}
}
for (const hpa of items.filter(
(item) => getKind(item) === "HorizontalPodAutoscaler",
)) {
const target = isRecord(hpa.spec?.scaleTargetRef)
? hpa.spec.scaleTargetRef
: undefined;
if (typeof target?.kind === "string" && typeof target.name === "string") {
pushEdge(edges, seen, {
from: getId(hpa),
to: `${target.kind}/${target.name}`,
type: "hpa",
});
}
}
for (const service of items.filter((item) => getKind(item) === "Service")) {
const selector = stringRecord(service.spec?.selector);
for (const pod of pods) {
if (matchesSelector(selector, pod.metadata?.labels)) {
pushEdge(edges, seen, {
from: getId(service),
to: getId(pod),
type: "service",
});
}
}
}
for (const ingress of items.filter((item) => getKind(item) === "Ingress")) {
const serviceNames = new Set<string>();
const defaultBackend = isRecord(ingress.spec?.defaultBackend)
? ingress.spec.defaultBackend
: undefined;
const defaultService = isRecord(defaultBackend?.service)
? defaultBackend.service
: undefined;
if (typeof defaultService?.name === "string")
serviceNames.add(defaultService.name);
const rules = Array.isArray(ingress.spec?.rules) ? ingress.spec.rules : [];
for (const rule of rules) {
if (!isRecord(rule) || !isRecord(rule.http)) continue;
const paths = Array.isArray(rule.http.paths) ? rule.http.paths : [];
for (const path of paths) {
if (!isRecord(path) || !isRecord(path.backend)) continue;
const service = isRecord(path.backend.service)
? path.backend.service
: undefined;
if (typeof service?.name === "string") serviceNames.add(service.name);
}
}
for (const name of serviceNames) {
pushEdge(edges, seen, {
from: getId(ingress),
to: `Service/${name}`,
type: "ingress",
});
}
}
for (const pod of pods) {
const volumes = Array.isArray(pod.spec?.volumes) ? pod.spec.volumes : [];
for (const volume of volumes) {
if (!isRecord(volume)) continue;
const pvc = isRecord(volume.persistentVolumeClaim)
? volume.persistentVolumeClaim
: undefined;
const configMap = isRecord(volume.configMap)
? volume.configMap
: undefined;
const secret = isRecord(volume.secret) ? volume.secret : undefined;
const targets = [
["PersistentVolumeClaim", pvc?.claimName],
["ConfigMap", configMap?.name],
["Secret", secret?.secretName],
] as const;
for (const [kind, name] of targets) {
if (typeof name === "string") {
pushEdge(edges, seen, {
from: getId(pod),
to: `${kind}/${name}`,
type: "volume",
});
}
}
}
}
return edges;
}
export function deriveNodeStatus(
object: GraphObject | undefined,
serviceTargetCount = 0,
): NodeStatus {
if (!object) return { label: "warn", level: "warn" };
const status = object.status;
switch (getKind(object)) {
case "Pod": {
const phase =
typeof status?.phase === "string" ? status.phase : "Unknown";
if (phase === "Running" && hasCondition(object, "Ready", "True"))
return { label: "ready", level: "good" };
if (phase === "Pending") return { label: "pending", level: "warn" };
if (phase === "Succeeded") return { label: "done", level: "good" };
return { label: phase.toLowerCase(), level: "bad" };
}
case "Deployment":
case "ReplicaSet":
case "StatefulSet": {
const ready = number(status?.readyReplicas) ?? 0;
const desired = number(status?.replicas) ?? 0;
if (desired === 0) return { label: "idle", level: "warn" };
if (ready === desired)
return { label: `${ready}/${desired}`, level: "good" };
if (ready > 0) return { label: `${ready}/${desired}`, level: "warn" };
return { label: `${ready}/${desired}`, level: "bad" };
}
case "DaemonSet": {
const ready = number(status?.numberReady) ?? 0;
const desired = number(status?.desiredNumberScheduled) ?? 0;
if (desired === 0) return { label: "idle", level: "warn" };
if (ready === desired)
return { label: `${ready}/${desired}`, level: "good" };
if (ready > 0) return { label: `${ready}/${desired}`, level: "warn" };
return { label: `${ready}/${desired}`, level: "bad" };
}
case "Job":
if (hasCondition(object, "Complete", "True"))
return { label: "done", level: "good" };
if (hasCondition(object, "Failed", "True"))
return { label: "failed", level: "bad" };
return { label: "running", level: "warn" };
case "CronJob":
return object.spec?.suspend === true
? { label: "paused", level: "warn" }
: { label: "ok", level: "good" };
case "PersistentVolumeClaim": {
const phase =
typeof status?.phase === "string" ? status.phase : "Unknown";
if (phase === "Bound") return { label: "bound", level: "good" };
if (phase === "Pending") return { label: "pending", level: "warn" };
return { label: phase.toLowerCase(), level: "bad" };
}
case "Service":
return serviceTargetCount > 0
? { label: `${serviceTargetCount} ep`, level: "good" }
: { label: "no ep", level: "warn" };
case "Ingress":
return { label: "route", level: "good" };
case "HorizontalPodAutoscaler": {
const current = number(status?.currentReplicas);
return current === undefined
? { label: "warn", level: "warn" }
: { label: `${current} cur`, level: "good" };
}
case "PodDisruptionBudget": {
const healthy = number(status?.currentHealthy) ?? 0;
const desired = number(status?.desiredHealthy) ?? 0;
if (healthy >= desired)
return { label: `${healthy}/${desired}`, level: "good" };
if (healthy > 0) return { label: `${healthy}/${desired}`, level: "warn" };
return { label: `${healthy}/${desired}`, level: "bad" };
}
case "ConfigMap":
case "Secret":
return { label: "ok", level: "good" };
default:
return { label: "ok", level: "warn" };
}
}
function isIdle(object: GraphObject | undefined): boolean {
return (
(getKind(object ?? {}) === "Deployment" ||
getKind(object ?? {}) === "ReplicaSet") &&
(number(object?.status?.replicas) ?? 0) === 0 &&
(number(object?.status?.readyReplicas) ?? 0) === 0
);
}
export function buildNamespaceGraphs(
items: GraphObject[],
options: { includeIdle?: boolean } = {},
): NamespaceGraph[] {
const byNamespace = new Map<string, GraphObject[]>();
for (const item of items) {
const bucket = byNamespace.get(getNamespace(item)) ?? [];
bucket.push(item);
byNamespace.set(getNamespace(item), bucket);
}
return [...byNamespace.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([namespace, namespaceItems]) => {
const objectById = new Map(
namespaceItems.map((item) => [getId(item), item]),
);
const edges = collectEdges(namespaceItems).sort((left, right) =>
`${left.from}->${left.to}`.localeCompare(`${right.from}->${right.to}`),
);
const allNodes = new Set(namespaceItems.map(getId));
const bestIncoming = new Map<string, Edge>();
const serviceTargets = new Map<string, Set<string>>();
for (const edge of edges) {
if (!objectById.has(edge.from) || !objectById.has(edge.to)) continue;
if (edge.type === "service") {
const targets = serviceTargets.get(edge.from) ?? new Set<string>();
targets.add(edge.to);
serviceTargets.set(edge.from, targets);
}
const current = bestIncoming.get(edge.to);
if (!current || priority(edge.type) > priority(current.type))
bestIncoming.set(edge.to, edge);
}
const children = new Map<string, string[]>();
for (const edge of bestIncoming.values()) {
const bucket = children.get(edge.from) ?? [];
bucket.push(edge.to);
children.set(edge.from, bucket);
}
const hidden = new Set<string>();
const hideSubtree = (id: string): void => {
if (hidden.has(id)) return;
hidden.add(id);
for (const child of children.get(id) ?? []) hideSubtree(child);
};
if (!options.includeIdle) {
for (const item of namespaceItems) {
if (getKind(item) === "Deployment" && isIdle(item))
hideSubtree(getId(item));
}
}
for (const [parent, childIds] of children) {
if (getKind(objectById.get(parent) ?? {}) !== "Deployment") continue;
const replicaSets = childIds.filter(
(id) => getKind(objectById.get(id) ?? {}) === "ReplicaSet",
);
if (!replicaSets.some((id) => !isIdle(objectById.get(id)))) continue;
for (const id of replicaSets.filter((id) => isIdle(objectById.get(id))))
hideSubtree(id);
children.set(
parent,
childIds.filter((id) => !hidden.has(id)),
);
}
const buildNode = (id: string): GraphNode => ({
id,
status: deriveNodeStatus(
objectById.get(id),
serviceTargets.get(id)?.size,
),
children: [...new Set(children.get(id) ?? [])]
.filter((child) => !hidden.has(child))
.sort((left, right) => left.localeCompare(right))
.map(buildNode),
});
const printed = new Set<string>();
const roots: GraphNode[] = [];
const addRoot = (id: string): void => {
if (hidden.has(id) || printed.has(id)) return;
const root = buildNode(id);
const mark = (node: GraphNode): void => {
printed.add(node.id);
node.children.forEach(mark);
};
mark(root);
roots.push(root);
};
[...allNodes]
.filter((id) => !bestIncoming.has(id))
.sort((left, right) => left.localeCompare(right))
.forEach(addRoot);
[...allNodes]
.sort((left, right) => left.localeCompare(right))
.forEach(addRoot);
return { namespace, roots };
});
}
export async function fetchNamespaceObjects(
namespace: string,
): Promise<GraphObject[]> {
const { objectApi } = await import("./k8s");
const lists = await Promise.all(
resources.map(async ([apiVersion, kind]) => {
const list = await objectApi.list(apiVersion, kind, namespace);
return list.items.map((item) => ({
...item,
apiVersion: item.apiVersion ?? apiVersion,
kind: item.kind ?? kind,
metadata: {
...item.metadata,
namespace: item.metadata?.namespace ?? namespace,
},
})) as GraphObject[];
}),
);
return lists.flat();
}
function badge(status: NodeStatus): string {
const background =
status.level === "good"
? ansi.bgGreen
: status.level === "bad"
? ansi.bgRed
: ansi.bgYellow;
return `${background}${ansi.fgBlack} ${status.label} ${ansi.reset}`;
}
function colorize(id: string): string {
const kind = id.split("/", 1)[0];
const color =
kind === "Deployment"
? ansi.fgBlue
: kind === "Service"
? ansi.fgPurple
: kind === "Ingress"
? ansi.fgYellow
: kind === "Pod"
? ansi.fgGreen
: undefined;
return color ? `${color}${id}${ansi.reset}` : id;
}
export function renderNamespaceGraphs(graphs: NamespaceGraph[]): string {
const lines: string[] = [];
const renderNode = (node: GraphNode, prefix: string, last: boolean): void => {
lines.push(
`${prefix}${last ? "└─ " : "├─ "}${colorize(node.id)} ${badge(node.status)}`,
);
const childPrefix = prefix + (last ? " " : "│ ");
node.children.forEach((child, index) => {
renderNode(child, childPrefix, index === node.children.length - 1);
});
};
for (const graph of graphs) {
lines.push(graph.namespace);
graph.roots.forEach((root, index) => {
renderNode(root, "", index === graph.roots.length - 1);
});
lines.push("");
}
return lines.join("\n");
}