mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
74b662d8de
Stand up a minikube-backed simulation subsystem for benching Windmill under realistic multi-node load, with a per-bench measurement pipeline and a dashboard renderer that consolidates throughput, queue depth, per-node CPU, PG latency/conns, OOM events, and per-node CPU-util-vs-oversaturation into one SVG report. Sim infrastructure (sim/): - k8s_provisioner: minikube up + heterogeneous node sizing from topology JSON - helm_deploy: helm install Windmill with smoke.yaml + local.yaml overlays - image_cache: pre-load required images so bench bringup is offline-safe - toxiproxy_k8s: per-node toxiproxy DaemonSet for cross-node latency injection - cpu_sampler_k8s: privileged DS reading per-cgroup cpu.stat at 10Hz, dual- writes to stdout AND a host-mounted log file (/var/log/wm-sim-cpu-sampler/ sampler.tsv) so heavy benches no longer lose early samples to kubelet log rotation - pg_logging: ALTER SYSTEM + SIGHUP to enable verbose PG logging without restart - pgbadger: post-bench PG log analysis HTML report - readiness: pre-bench cluster health check (samplers stable ≥30s, workers ready, PG responsive, queue empty, **deploy.status rollout-complete**) — the rollout-complete check catches mid-rolling-update fires that previously starved m04's sampler under cgroup_mutex contention Per-bench JSONL pollers, started/finalized alongside the bench loop: - pod_timeline: 1Hz workers-per-node Ready counts (used for the workers panel) - oom_poller: live OOM event capture (kernel + kubelet evictions + cgroup) - pg_latency_poller: 4Hz psql \\timing on SELECT 1 vs kubectl-exec roundtrip - pg_conn_poller: 1Hz pg_stat_activity by state (active/idle/idle_in_xact) - node_load_poller: 2Hz /proc/loadavg + /proc/stat procs_running per node Dashboard renderer (sim/render_report.ts + graph.ts): - Util group: one panel per node with translucent orange oversaturation area BEHIND solid blue CPU-util area, 100% reference line, phase-boundary verticals. cols:2 grid wraps after 2 panels per row. - PG node tinted with [PG] flag in legend across the dashboard. - Phase-boundary verticals + push-window shaded zones layered consistently. - All x-axes switched from wall-clock HH:MM to relative seconds-from-bench- start. Shared origin sourced from meta.json's bench_start_ms so 0s on every panel = the same wall-clock moment (previously each chart picked its own earliest sample as origin, causing drift between panels). Oversaturation metric, with explicit fallback: - Primary: (procs_running - ncpu) / ncpu × 100 — true CPU run-queue pressure. - Fallback to load1 when procs_running is missing (older reports). - load1 overcounted previously because it includes uninterruptible D-state procs (PG backends in disk I/O, cgroup_mutex waits), inflating "saturation" by 5-10x under load. - Pure helper extracted to sim/util_metrics.ts; 8 unit tests cover the procs_running > load1 preference, the clamp-at-zero, invalid-ncpu cases. Sampler reliability: - HostPath log file in addition to stdout so the bench's scp-based collector bypasses kubelet log rotation entirely. - main.ts truncates the host log file on every node before pushers start (parallel ssh, best-effort) so it doesn't grow unbounded across runs. - Collector falls back to kubectl-logs when scp fails for any node. Workloads (workloads/): - io_4phase: four-phase IO step (idle → 2.5s → 500ms → 150ms jobs) - io_150ms_flood / io_300ms_flood / io_1s_flood / io_2s_flood: single-phase flood configs to isolate the worker-host CFS context-switch storm vs PG contention regime - burst, ops_day, cpu_*, etc. for other scenarios Tests: - sim/util_metrics_test.ts — 8 cases for computeOversatPct - sim/util_panel_snapshot_test.ts — 5 assertions guarding util-panel SVG invariants (orange behind blue, 100% ref line, relative-time ticks NOT wall-clock, phase-boundary verticals, shared-origin override) Helm values: - sim/values/smoke.yaml — bench-tuned: workers w/ no CPU limit & low mem request, PG w/ 3-core request + wm-critical priorityClass + oomImmune + maxConnections, app w/ wm-critical + oomImmune + no resource limits. - sim/values/local.example.yaml — template for the gitignored local.yaml that carries the EE license key. - Depends on the wm-critical PriorityClass + oomImmune + maxConnections knobs landing in windmill-helm-charts (separate PR). graph.ts additions: - areaFills param: ordered list of per-kind translucent area fills drawn before lines, used by the util panel for orange-behind-blue layering - lineColorOverrides: pin per-kind line colors so oversaturation reliably renders orange regardless of d3 ordinal-color insertion order - highlightKindToken: substring-match flag for the PG-node tint in Node CPU - xRelativeOriginMs: shared bench-start origin for the relative-time x-axis - DataPointMulti is now exported for downstream tests Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
793 lines
24 KiB
TypeScript
793 lines
24 KiB
TypeScript
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
|
||
import { JSDOM } from "https://jspm.dev/jsdom@22";
|
||
|
||
type DataPoint = {
|
||
value: number;
|
||
date: Date;
|
||
};
|
||
export function drawGraph(
|
||
data: DataPoint[],
|
||
title: string,
|
||
yLabel: string = "[jobs/s]",
|
||
yMax?: number,
|
||
) {
|
||
const context = {
|
||
jsdom: new JSDOM(""),
|
||
};
|
||
const { window } = context.jsdom;
|
||
const { document } = window;
|
||
|
||
const body = d3.select(document).select("body");
|
||
|
||
// Time-series chart — 900px wide so the many-points lines (throughput,
|
||
// CPU util, memory, PG memory etc.) have room to read individual peaks.
|
||
// drawBars/drawDonut below stay at 600 since bar charts don't benefit
|
||
// from extra width.
|
||
const width = 900;
|
||
const height = 200;
|
||
|
||
const marginTop = 20;
|
||
const marginRight = 30;
|
||
const marginBottom = 30;
|
||
const marginLeft = 60;
|
||
|
||
let svg = body
|
||
.append("svg")
|
||
.attr("xmlns", "http://www.w3.org/2000/svg")
|
||
.attr("width", width + marginLeft + marginRight)
|
||
.attr("height", height + marginTop + marginBottom);
|
||
|
||
svg
|
||
.append("rect")
|
||
.attr("width", "100%")
|
||
.attr("height", "100%")
|
||
.attr("fill", "white");
|
||
|
||
svg = svg
|
||
.append("g")
|
||
.attr("transform", "translate(" + marginLeft + "," + marginTop + ")");
|
||
|
||
const x = d3
|
||
.scaleTime()
|
||
.domain(
|
||
d3.extent(data, function (d: DataPoint) {
|
||
return d.date;
|
||
})
|
||
)
|
||
// No .nice() on the time scale — it rounds the domain to "nice" tick
|
||
// boundaries (e.g. extends domain to next 30s mark), pushing the first
|
||
// few seconds of data off the left edge so phase 1 (idle baseline) and
|
||
// m02's CPU curve appear to "start from the middle". Exact data extent
|
||
// keeps the line starting at the actual first sample.
|
||
.range([0, width]);
|
||
|
||
// Relative-time x-axis: labels show seconds since the earliest data point
|
||
// in this chart ("0s, 30s, 60s ..."). Wall-clock HH:MM:SS labels were
|
||
// confusing — they alternated between "03:45" and ":30" formats and made
|
||
// same-time comparisons across panels hard. Relative units anchor every
|
||
// panel at 0s.
|
||
const xDom = x.domain() as [Date, Date];
|
||
const xOriginMs = xRelativeOriginMs ?? xDom[0].getTime();
|
||
const xAxis = d3.axisBottom(x).ticks(5).tickFormat((d) => {
|
||
const sec = Math.round(((d as Date).getTime() - xOriginMs) / 1000);
|
||
return `${sec}s`;
|
||
});
|
||
|
||
svg
|
||
.append("g")
|
||
.attr("transform", "translate(0," + height + ")")
|
||
.call(xAxis);
|
||
|
||
// Add Y axis. When yMax is set, also `.clamp(true)` so out-of-range data
|
||
// values (e.g. a CPU spike to 250% on a chart capped at 150%) render at the
|
||
// axis boundary instead of escaping the chart area entirely.
|
||
const y = d3
|
||
.scaleLinear()
|
||
.domain([
|
||
0,
|
||
yMax !== undefined ? yMax : d3.max(data, function (d: DataPoint) {
|
||
return +d.value;
|
||
}) * 1.5,
|
||
])
|
||
.range([height, 0])
|
||
.clamp(yMax !== undefined)
|
||
.nice();
|
||
svg.append("g").call(d3.axisLeft(y));
|
||
|
||
svg
|
||
.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("style", "font-size: 12px")
|
||
.attr("transform", "rotate(-90)")
|
||
.attr("y", -marginLeft + 20)
|
||
.attr("x", -height / 2)
|
||
.text(yLabel);
|
||
|
||
svg
|
||
.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("style", "font-size: 16px")
|
||
.attr("y", 0)
|
||
.attr("x", width / 2)
|
||
.text(title);
|
||
|
||
// Add the line
|
||
svg
|
||
.append("path")
|
||
.datum(data)
|
||
.attr("fill", "none")
|
||
.attr("stroke", "steelblue")
|
||
.attr("stroke-width", 1.5)
|
||
.attr(
|
||
"d",
|
||
d3.line(
|
||
function (d: DataPoint) {
|
||
return x(d.date);
|
||
},
|
||
function (d: DataPoint) {
|
||
return y(d.value);
|
||
}
|
||
)
|
||
);
|
||
|
||
return body.node().innerHTML;
|
||
}
|
||
|
||
export interface DataPointMulti extends DataPoint {
|
||
kind: string;
|
||
}
|
||
|
||
export function drawGraphMulti(
|
||
data: DataPointMulti[],
|
||
title: string,
|
||
yLabel: string = "[jobs/s]",
|
||
// When set, fixes the chart Y axis at [0, yMax] instead of auto-scaling to
|
||
// 1.5x the max sample. Use for percent-based panels (Node CPU at 150 caps
|
||
// noise spikes that would otherwise compress the rest of the chart).
|
||
yMax?: number,
|
||
// Optional vertical dashed lines at given timestamps. Used by the phased
|
||
// bench to mark phase boundaries (warmup → peak → cooldown, etc.) on every
|
||
// time-series panel so you can attribute throughput/CPU shifts to phases.
|
||
// Accepts a plain Date[] (lines + labels drawn) or `{ dates, hideLabels }`
|
||
// — the renderer suppresses the P1>P2 labels on charts other than the
|
||
// "primary" one so the dashboard doesn't end up with 27 boundary labels
|
||
// (3 per chart × 9 charts) which reads as duplication side-by-side.
|
||
verticalLines?: Date[] | { dates: Date[]; hideLabels?: boolean },
|
||
// Optional horizontal reference lines at given y-values (in chart units).
|
||
// Drawn as labeled dashed grey lines — used to show e.g. the 100% per-VM
|
||
// ceiling on the Node CPU chart so saturation is obvious at a glance.
|
||
horizontalLines?: { y: number; label: string }[],
|
||
// Optional translucent shaded rectangles spanning [from, to] on the x-axis.
|
||
// Used to mark the "push window" on Throughput / Queue depth / Node CPU so
|
||
// it's visually obvious which range is "bench actively pushing" vs
|
||
// "drain only" without staring at vertical phase boundaries.
|
||
shadedZones?: { from: Date; to: Date; fill?: string; label?: string }[],
|
||
// Optional substring match: if a series' `kind` contains this token, render
|
||
// a translucent colored area fill under its curve so it stands out as the
|
||
// "tinted" series. Used to flag the PG-hosting node in Node CPU.
|
||
highlightKindToken?: string,
|
||
// Optional ordered list of area fills drawn BEFORE the lines (back to front
|
||
// in this order, so the first entry is the backmost). Each entry tints any
|
||
// series whose `kind` exactly equals `kind`. Used for the util group where
|
||
// we want oversaturation as the backmost orange band with CPU util on top.
|
||
areaFills?: { kind: string; color: string; opacity?: number }[],
|
||
// Optional per-kind line color override. Without it, lines use d3's
|
||
// schemeCategory10 in the order kinds were inserted into sumstat — which
|
||
// means "oversaturation" wouldn't naturally come out orange. Used by the
|
||
// util group to pin each series' line color to its area fill color.
|
||
lineColorOverrides?: Record<string, string>,
|
||
// Optional shared origin (epoch ms) for the relative-time x-axis. When
|
||
// unset, each chart uses its own earliest data point — fine in isolation
|
||
// but inconsistent across panels because pollers / pushers / CPU samples
|
||
// start at slightly different moments. Pass meta.json's `bench_start_ms`
|
||
// here so 0s on every panel is the same wall-clock moment.
|
||
xRelativeOriginMs?: number,
|
||
) {
|
||
const context = {
|
||
jsdom: new JSDOM(""),
|
||
};
|
||
const { window } = context.jsdom;
|
||
const { document } = window;
|
||
|
||
const body = d3.select(document).select("body");
|
||
|
||
// Multi-series time chart — 900px wide for the same reason as drawGraph above.
|
||
// Throughput, Queue depth, Node CPU/memory, PG memory, Failed jobs, Workers per
|
||
// node all flow through this; bumping width here is the user-requested
|
||
// "make the lots-of-points charts 50% wider".
|
||
const width = 900;
|
||
const height = 200;
|
||
|
||
const marginTop = 20;
|
||
const marginRight = 100;
|
||
const marginBottom = 30;
|
||
const marginLeft = 60;
|
||
|
||
let svg = body
|
||
.append("svg")
|
||
.attr("xmlns", "http://www.w3.org/2000/svg")
|
||
.attr("width", width + marginLeft + marginRight)
|
||
.attr("height", height + marginTop + marginBottom);
|
||
|
||
svg
|
||
.append("rect")
|
||
.attr("width", "100%")
|
||
.attr("height", "100%")
|
||
.attr("fill", "white");
|
||
|
||
svg = svg
|
||
.append("g")
|
||
.attr("transform", "translate(" + marginLeft + "," + marginTop + ")");
|
||
|
||
const x = d3
|
||
.scaleTime()
|
||
.domain(
|
||
d3.extent(data, function (d: DataPoint) {
|
||
return d.date;
|
||
})
|
||
)
|
||
// No .nice() on the time scale — it rounds the domain to "nice" tick
|
||
// boundaries (e.g. extends domain to next 30s mark), pushing the first
|
||
// few seconds of data off the left edge so phase 1 (idle baseline) and
|
||
// m02's CPU curve appear to "start from the middle". Exact data extent
|
||
// keeps the line starting at the actual first sample.
|
||
.range([0, width]);
|
||
|
||
// Relative-time x-axis: labels show seconds since the earliest data point
|
||
// in this chart ("0s, 30s, 60s ..."). Wall-clock HH:MM:SS labels were
|
||
// confusing — they alternated between "03:45" and ":30" formats and made
|
||
// same-time comparisons across panels hard. Relative units anchor every
|
||
// panel at 0s.
|
||
const xDom = x.domain() as [Date, Date];
|
||
const xOriginMs = xRelativeOriginMs ?? xDom[0].getTime();
|
||
const xAxis = d3.axisBottom(x).ticks(5).tickFormat((d) => {
|
||
const sec = Math.round(((d as Date).getTime() - xOriginMs) / 1000);
|
||
return `${sec}s`;
|
||
});
|
||
|
||
svg
|
||
.append("g")
|
||
.attr("transform", "translate(0," + height + ")")
|
||
.call(xAxis);
|
||
|
||
// Add Y axis. When yMax is set, also `.clamp(true)` so out-of-range data
|
||
// values (e.g. a CPU spike to 250% on a chart capped at 150%) render at the
|
||
// axis boundary instead of escaping the chart area entirely.
|
||
const y = d3
|
||
.scaleLinear()
|
||
.domain([
|
||
0,
|
||
yMax !== undefined ? yMax : d3.max(data, function (d: DataPoint) {
|
||
return +d.value;
|
||
}) * 1.5,
|
||
])
|
||
.range([height, 0])
|
||
.clamp(yMax !== undefined)
|
||
.nice();
|
||
svg.append("g").call(d3.axisLeft(y));
|
||
|
||
svg
|
||
.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("style", "font-size: 12px")
|
||
.attr("transform", "rotate(-90)")
|
||
.attr("y", -marginLeft + 20)
|
||
.attr("x", -height / 2)
|
||
.text(yLabel);
|
||
|
||
svg
|
||
.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("style", "font-size: 16px")
|
||
.attr("y", 0)
|
||
.attr("x", width / 2)
|
||
.text(title);
|
||
|
||
const sumstat = d3.group(data, function (d: DataPointMulti) {
|
||
return d.kind;
|
||
});
|
||
|
||
const keys = Array.from(sumstat.keys());
|
||
|
||
const color = d3
|
||
.scaleOrdinal()
|
||
.domain(keys)
|
||
.range([
|
||
"#e41a1c",
|
||
"#377eb8",
|
||
"#4daf4a",
|
||
"#984ea3",
|
||
"#ff7f00",
|
||
"#ffff33",
|
||
"#a65628",
|
||
"#f781bf",
|
||
"#999999",
|
||
]);
|
||
|
||
// Shaded zones (e.g. push window) — drawn BEFORE the lines so lines
|
||
// paint on top. Translucent fill so the chart underneath stays
|
||
// readable. Optional label hugs the top-left of the zone.
|
||
if (shadedZones && shadedZones.length > 0) {
|
||
svg
|
||
.selectAll(".shaded-zone")
|
||
.data(shadedZones)
|
||
.enter()
|
||
.append("rect")
|
||
.attr("class", "shaded-zone")
|
||
.attr("x", (d: { from: Date }) => x(d.from))
|
||
.attr("width", (d: { from: Date; to: Date }) => Math.max(0, x(d.to) - x(d.from)))
|
||
.attr("y", 0)
|
||
.attr("height", height)
|
||
.attr("fill", (d: { fill?: string }) => d.fill ?? "#1f77b4")
|
||
.attr("fill-opacity", 0.08)
|
||
.attr("stroke", "none");
|
||
svg
|
||
.selectAll(".shaded-zone-label")
|
||
.data(shadedZones.filter((z: { label?: string }) => z.label))
|
||
.enter()
|
||
.append("text")
|
||
.attr("class", "shaded-zone-label")
|
||
.attr("x", (d: { from: Date }) => x(d.from) + 4)
|
||
.attr("y", 22)
|
||
.attr("text-anchor", "start")
|
||
.style("font-size", "10px")
|
||
.style("font-weight", "600")
|
||
.style("fill", "#3a5d8a")
|
||
.style("font-family", "monospace")
|
||
.text((d: { label?: string }) => d.label!);
|
||
}
|
||
|
||
// Explicit area-fills list — drawn in order so caller controls back-to-front
|
||
// z-order. Each is the area under the matching series' curve.
|
||
if (areaFills && areaFills.length > 0) {
|
||
const entries = Array.from(sumstat as any) as any[];
|
||
for (const fill of areaFills) {
|
||
const match = entries.find((e: any) => String(e[0]) === fill.kind);
|
||
if (!match) continue;
|
||
svg.append("path")
|
||
.attr("class", "area-fill")
|
||
.attr("fill", fill.color)
|
||
.attr("fill-opacity", fill.opacity ?? 0.25)
|
||
.attr("stroke", "none")
|
||
.attr("d", d3
|
||
.area()
|
||
.x((p: any) => x(p.date))
|
||
.y0(height)
|
||
.y1((p: any) => y(p.value))(match[1]));
|
||
}
|
||
}
|
||
|
||
// Tinted area fill under the highlighted series — drawn BEFORE the line so
|
||
// line stays crisp on top. Translucent so the underlying x-axis and
|
||
// overlapping series remain visible. sumstat is a d3.InternMap (Map-like);
|
||
// convert to entries Array to filter.
|
||
if (highlightKindToken) {
|
||
const highlightSeries = Array.from(sumstat as any).filter(
|
||
(d: any) => String(d[0]).includes(highlightKindToken),
|
||
);
|
||
svg
|
||
.selectAll("path.tint")
|
||
.data(highlightSeries)
|
||
.join("path")
|
||
.attr("class", "tint")
|
||
.attr("fill", function (d: any) { return color(d[0]); })
|
||
.attr("fill-opacity", 0.18)
|
||
.attr("stroke", "none")
|
||
.attr("d", (d: any) => {
|
||
return d3
|
||
.area()
|
||
.x((p: any) => x(p.date))
|
||
.y0(height)
|
||
.y1((p: any) => y(p.value))(d[1]);
|
||
});
|
||
}
|
||
|
||
// Add the line
|
||
svg
|
||
.selectAll("path.line")
|
||
.data(sumstat)
|
||
.join("path")
|
||
.attr("class", "line")
|
||
.attr("fill", "none")
|
||
.attr("stroke", function (d) {
|
||
return (lineColorOverrides && lineColorOverrides[String(d[0])]) ?? color(d[0]);
|
||
})
|
||
.attr("stroke-width", 1.5)
|
||
.attr("d", (d) => {
|
||
return d3
|
||
.line()
|
||
.x((d) => x(d.date))
|
||
.y((d) => y(d.value))(d[1]);
|
||
});
|
||
|
||
const size = 15;
|
||
svg
|
||
.selectAll(".dot")
|
||
.data(keys)
|
||
.enter()
|
||
.append("rect")
|
||
.attr("class", "dot")
|
||
.attr("x", 400)
|
||
.attr("y", function (d, i) {
|
||
return 5 + i * (size + 5);
|
||
})
|
||
.attr("width", size)
|
||
.attr("height", size)
|
||
.style("fill", function (d) {
|
||
return color(d);
|
||
});
|
||
svg
|
||
.selectAll(".label")
|
||
.data(keys)
|
||
.enter()
|
||
.append("text")
|
||
.attr("class", "label")
|
||
.attr("x", 400 + size * 1.2)
|
||
.attr("y", function (d, i) {
|
||
return 5 + i * (size + 5) + size / 2;
|
||
})
|
||
.style("fill", function (d) {
|
||
return color(d);
|
||
})
|
||
.text(function (d) {
|
||
return d;
|
||
})
|
||
.attr("text-anchor", "left")
|
||
.style("alignment-baseline", "middle");
|
||
|
||
// Phase-boundary dashed verticals. Drawn last so they overlay the data lines.
|
||
// Each line gets a tiny "P{n}>P{n+1}" label at the top so the migration is
|
||
// visible at a glance — useful for phased benches where throughput / CPU
|
||
// shifts between phases.
|
||
// Normalize verticalLines into { dates, hideLabels }.
|
||
const vlNormalized = Array.isArray(verticalLines)
|
||
? { dates: verticalLines, hideLabels: false }
|
||
: verticalLines;
|
||
if (vlNormalized && vlNormalized.dates.length > 0) {
|
||
svg
|
||
.selectAll(".phase-boundary")
|
||
.data(vlNormalized.dates)
|
||
.enter()
|
||
.append("line")
|
||
.attr("class", "phase-boundary")
|
||
.attr("x1", (d: Date) => x(d))
|
||
.attr("x2", (d: Date) => x(d))
|
||
.attr("y1", 0)
|
||
.attr("y2", height)
|
||
.attr("stroke", "#888")
|
||
.attr("stroke-width", 1)
|
||
.attr("stroke-dasharray", "3 3");
|
||
if (!vlNormalized.hideLabels) {
|
||
svg
|
||
.selectAll(".phase-boundary-label")
|
||
.data(vlNormalized.dates)
|
||
.enter()
|
||
.append("text")
|
||
.attr("class", "phase-boundary-label")
|
||
.attr("x", (d: Date) => x(d) + 2)
|
||
.attr("y", 10)
|
||
.attr("text-anchor", "start")
|
||
.style("font-size", "9px")
|
||
.style("fill", "#666")
|
||
.style("font-family", "monospace")
|
||
.text((_: Date, i: number) => `P${i + 1}>P${i + 2}`);
|
||
}
|
||
}
|
||
|
||
// Horizontal reference lines (e.g. CPU 100% ceiling) — labeled dashed
|
||
// grey, with the label hugging the right edge so it doesn't collide
|
||
// with the data lines.
|
||
if (horizontalLines && horizontalLines.length > 0) {
|
||
svg
|
||
.selectAll(".h-ref")
|
||
.data(horizontalLines)
|
||
.enter()
|
||
.append("line")
|
||
.attr("class", "h-ref")
|
||
.attr("x1", 0)
|
||
.attr("x2", width)
|
||
.attr("y1", (d: { y: number; label: string }) => y(d.y))
|
||
.attr("y2", (d: { y: number; label: string }) => y(d.y))
|
||
.attr("stroke", "#999")
|
||
.attr("stroke-width", 1)
|
||
.attr("stroke-dasharray", "4 4");
|
||
svg
|
||
.selectAll(".h-ref-label")
|
||
.data(horizontalLines)
|
||
.enter()
|
||
.append("text")
|
||
.attr("class", "h-ref-label")
|
||
.attr("x", width - 4)
|
||
.attr("y", (d: { y: number; label: string }) => y(d.y) - 4)
|
||
.attr("text-anchor", "end")
|
||
.style("font-size", "9px")
|
||
.style("fill", "#666")
|
||
.style("font-family", "monospace")
|
||
.text((d: { y: number; label: string }) => d.label);
|
||
}
|
||
|
||
return body.node().innerHTML;
|
||
}
|
||
|
||
// Bar chart for one-dimensional distributions. Each bin is { label, count }.
|
||
// Works for both continuous (histogram bins, label = midpoint as string) and
|
||
// categorical (label = category name). Summary stats are rendered as text in
|
||
// the top-right.
|
||
export function drawBars(
|
||
bins: { label: string; count: number; color?: string }[],
|
||
title: string,
|
||
xLabel: string,
|
||
stats?: { min: number; max: number; avg: number },
|
||
opts: { rotateDeg?: number; fontSize?: string } = {},
|
||
): string {
|
||
const context = { jsdom: new JSDOM("") };
|
||
const { document } = context.jsdom.window;
|
||
const body = d3.select(document).select("body");
|
||
|
||
const width = 600;
|
||
const height = 200;
|
||
const marginTop = 30;
|
||
const marginRight = 30;
|
||
// 100px bottom margin so long rotated tick labels (e.g. OOM panel pod
|
||
// names like "windmill-postgresql-0 (12.5G) (cgroup)") aren't cropped.
|
||
const marginBottom = 100;
|
||
const marginLeft = 60;
|
||
|
||
let svg = body
|
||
.append("svg")
|
||
.attr("xmlns", "http://www.w3.org/2000/svg")
|
||
.attr("width", width + marginLeft + marginRight)
|
||
.attr("height", height + marginTop + marginBottom);
|
||
|
||
svg
|
||
.append("rect")
|
||
.attr("width", "100%")
|
||
.attr("height", "100%")
|
||
.attr("fill", "white");
|
||
|
||
svg = svg
|
||
.append("g")
|
||
.attr("transform", "translate(" + marginLeft + "," + marginTop + ")");
|
||
|
||
// Title
|
||
svg
|
||
.append("text")
|
||
.attr("x", width / 2)
|
||
.attr("y", -10)
|
||
.attr("text-anchor", "middle")
|
||
.style("font-size", "14px")
|
||
.style("font-weight", "600")
|
||
.text(title);
|
||
|
||
const x = d3.scaleBand()
|
||
.domain(bins.map((b) => b.label))
|
||
.range([0, width])
|
||
.padding(0.1);
|
||
|
||
const y = d3.scaleLinear()
|
||
.domain([0, d3.max(bins, (b) => b.count) || 1])
|
||
.nice()
|
||
.range([height, 0]);
|
||
|
||
// X axis — label EVERY bar so the user can read what each one is.
|
||
// rotateDeg / fontSize are caller-tunable: -45° + 10px is fine for
|
||
// ~12 bars; -90° + smaller font lets a chart fit ~100 labels (per-pod
|
||
// memory bars on a ~80-worker cluster).
|
||
const rotateDeg = opts.rotateDeg ?? -45;
|
||
const fontSize = opts.fontSize ?? (bins.length > 12 ? "9px" : "10px");
|
||
svg.append("g")
|
||
.attr("transform", "translate(0," + height + ")")
|
||
.call(
|
||
d3.axisBottom(x).tickValues(bins.map((b) => b.label)),
|
||
)
|
||
.selectAll("text")
|
||
.attr("transform", `rotate(${rotateDeg})`)
|
||
.style("text-anchor", "end")
|
||
.style("font-size", fontSize);
|
||
|
||
svg.append("text")
|
||
.attr("x", width / 2)
|
||
.attr("y", height + 90) // ↓ below rotated tick labels (was 45)
|
||
.attr("text-anchor", "middle")
|
||
.style("font-size", "11px")
|
||
.text(xLabel);
|
||
|
||
svg.append("g").call(d3.axisLeft(y));
|
||
|
||
svg.selectAll(".bar")
|
||
.data(bins)
|
||
.join("rect")
|
||
.attr("class", "bar")
|
||
.attr("x", (b) => x(b.label) || 0)
|
||
.attr("y", (b) => y(b.count))
|
||
.attr("width", x.bandwidth())
|
||
.attr("height", (b) => height - y(b.count))
|
||
.attr("fill", (b: { color?: string }) => b.color ?? "#377eb8");
|
||
|
||
// Dashed vertical divider lines between contiguous groups of same-color
|
||
// bars. With the Pod inventory chart (one node = one color), this draws a
|
||
// separator between m02's run of bars and m03's, etc., making it
|
||
// visually obvious which bars belong to which node.
|
||
const groupBoundaries: number[] = [];
|
||
for (let i = 1; i < bins.length; i++) {
|
||
if (bins[i].color !== bins[i - 1].color) groupBoundaries.push(i);
|
||
}
|
||
if (groupBoundaries.length > 0) {
|
||
const bandStep = x.step();
|
||
svg.selectAll(".group-divider")
|
||
.data(groupBoundaries)
|
||
.enter()
|
||
.append("line")
|
||
.attr("class", "group-divider")
|
||
.attr("x1", (i: number) => (x(bins[i].label) || 0) - bandStep * (1 - x.bandwidth() / bandStep) / 2)
|
||
.attr("x2", (i: number) => (x(bins[i].label) || 0) - bandStep * (1 - x.bandwidth() / bandStep) / 2)
|
||
.attr("y1", 0)
|
||
.attr("y2", height + 5)
|
||
.attr("stroke", "#aaa")
|
||
.attr("stroke-width", 1)
|
||
.attr("stroke-dasharray", "3 3");
|
||
}
|
||
|
||
// Stats text top-right.
|
||
if (stats) {
|
||
const fmt = (n: number) =>
|
||
n >= 1000 ? n.toFixed(0) : n >= 10 ? n.toFixed(1) : n.toFixed(2);
|
||
const lines = [
|
||
`min: ${fmt(stats.min)}`,
|
||
`avg: ${fmt(stats.avg)}`,
|
||
`max: ${fmt(stats.max)}`,
|
||
];
|
||
svg.selectAll(".stat")
|
||
.data(lines)
|
||
.enter()
|
||
.append("text")
|
||
.attr("class", "stat")
|
||
.attr("x", width)
|
||
.attr("y", (_, i) => 12 + i * 14)
|
||
.attr("text-anchor", "end")
|
||
.style("font-size", "11px")
|
||
.style("font-family", "monospace")
|
||
.style("fill", "#555")
|
||
.text((d) => d);
|
||
}
|
||
|
||
return body.node().innerHTML;
|
||
}
|
||
|
||
// Donut chart for categorical distributions. Slices labeled with category +
|
||
// percentage; legend on the right.
|
||
export function drawDonut(
|
||
slices: { label: string; count: number }[],
|
||
title: string,
|
||
): string {
|
||
const context = { jsdom: new JSDOM("") };
|
||
const { document } = context.jsdom.window;
|
||
const body = d3.select(document).select("body");
|
||
|
||
const width = 600;
|
||
const height = 220;
|
||
const marginTop = 30;
|
||
const marginBottom = 10;
|
||
const marginLeft = 20;
|
||
const marginRight = 20;
|
||
|
||
const total = slices.reduce((a, b) => a + b.count, 0) || 1;
|
||
|
||
let svg = body
|
||
.append("svg")
|
||
.attr("xmlns", "http://www.w3.org/2000/svg")
|
||
.attr("width", width + marginLeft + marginRight)
|
||
.attr("height", height + marginTop + marginBottom);
|
||
|
||
svg
|
||
.append("rect")
|
||
.attr("width", "100%")
|
||
.attr("height", "100%")
|
||
.attr("fill", "white");
|
||
|
||
// Title
|
||
svg
|
||
.append("text")
|
||
.attr("x", (width + marginLeft + marginRight) / 2)
|
||
.attr("y", marginTop - 8)
|
||
.attr("text-anchor", "middle")
|
||
.style("font-size", "14px")
|
||
.style("font-weight", "600")
|
||
.text(title);
|
||
|
||
const r = Math.min(width, height) / 2 - 10;
|
||
const cx = marginLeft + r + 10;
|
||
const cy = marginTop + height / 2;
|
||
|
||
const palette = [
|
||
"#377eb8", "#e41a1c", "#4daf4a", "#984ea3",
|
||
"#ff7f00", "#a65628", "#f781bf", "#999999",
|
||
];
|
||
const color = (i: number) => palette[i % palette.length];
|
||
|
||
const pie = d3.pie<{ label: string; count: number }>().value((d) => d.count).sort(null);
|
||
const arc = d3.arc<d3.PieArcDatum<{ label: string; count: number }>>()
|
||
.innerRadius(r * 0.55)
|
||
.outerRadius(r);
|
||
|
||
const arcs = pie(slices);
|
||
const g = svg.append("g").attr("transform", `translate(${cx},${cy})`);
|
||
|
||
g.selectAll("path")
|
||
.data(arcs)
|
||
.join("path")
|
||
.attr("d", arc as never)
|
||
.attr("fill", (_, i) => color(i))
|
||
.attr("stroke", "white")
|
||
.attr("stroke-width", 2);
|
||
|
||
// Legend on the right
|
||
const legendX = cx + r + 30;
|
||
const legendY = marginTop + 20;
|
||
const sw = 12;
|
||
slices.forEach((s, i) => {
|
||
const pct = ((s.count / total) * 100).toFixed(1);
|
||
svg.append("rect")
|
||
.attr("x", legendX)
|
||
.attr("y", legendY + i * 22)
|
||
.attr("width", sw)
|
||
.attr("height", sw)
|
||
.attr("fill", color(i));
|
||
svg.append("text")
|
||
.attr("x", legendX + sw + 6)
|
||
.attr("y", legendY + i * 22 + sw - 1)
|
||
.style("font-size", "12px")
|
||
.style("font-family", "monospace")
|
||
.style("fill", "#333")
|
||
.text(`${s.label} ${pct}%`);
|
||
});
|
||
|
||
return body.node().innerHTML;
|
||
}
|
||
|
||
if (import.meta.main) {
|
||
const svg = drawGraph(
|
||
[
|
||
{
|
||
value: 10,
|
||
date: new Date(86400000),
|
||
},
|
||
{
|
||
value: 12,
|
||
date: new Date(86400000 * 2),
|
||
},
|
||
],
|
||
"test"
|
||
);
|
||
|
||
const svg2 = drawGraphMulti(
|
||
[
|
||
{
|
||
value: 10,
|
||
date: new Date(86400000),
|
||
kind: "test",
|
||
},
|
||
{
|
||
value: 12,
|
||
date: new Date(86400000 * 2),
|
||
kind: "test",
|
||
},
|
||
{
|
||
value: 8,
|
||
date: new Date(86400000),
|
||
kind: "test2",
|
||
},
|
||
{
|
||
value: 9,
|
||
date: new Date(86400000 * 2),
|
||
kind: "test2",
|
||
},
|
||
],
|
||
"test"
|
||
);
|
||
|
||
console.log(svg);
|
||
console.log(svg2);
|
||
Deno.exit(0);
|
||
}
|