Project story

The platform map

The platform map is an operations index for a K3s and Proxmox lab.

How it is built, in one paragraph

A small Python service runs inside the K3s monitoring namespace and queries the Prometheus API for Kubernetes objects, virtual infrastructure, probes, pipelines, certificates, security services, backup, mail, and AI telemetry. It turns those metrics into one ownership graph. A second pod publishes a sanitized copy for the public site.

Measured from the public feed at publish: 800 components.

How to read it in thirty seconds

The center is the questionAt the root, the question is “which operating area needs attention?” After a click, the center becomes that area or system.
The ring is the answer setChildren are the objects owned by the center. For Kubernetes that can be namespaces, workloads, pods, and containers.
The strip is the evidenceDesired and ready replicas, CPU, memory, runtime, reachability, and safe alert text follow the selected object.
View the code →

At a glance

Outcome
An interactive ownership map turns current platform signals into a drill-down from operating area to the component that needs attention.
Operating scope
Dedicated builders read Prometheus for Kubernetes, virtual infrastructure, delivery, and shared services. A separate publisher sends a sanitized graph outward.
Evidence
The public feed supplies the current component total on this page. Fifteen-second collection, a 30-second edge cache, and a 120-second cutoff keep freshness explicit.
Primary lesson
The public graph is rebuilt from an allowlist and checked as one document before it replaces the previous accepted snapshot.

Click any node to drill into it. Click again to open the next layer.

This is the interactive public map, not a screenshot. Select a node, follow the breadcrumb back, and watch the evidence strip change. If the embedded preview is unavailable, open the platform map directly.

The running system

Runtime

Two pods in the existing K3s cluster

The map server is a single Python container in the monitoring namespace. It listens on port 8080 behind a ClusterIP Service and the private Traefik Ingress. It caches each graph for 15 seconds.

The pod runs as an unprivileged user with a read-only root filesystem, dropped Linux capabilities, and no Kubernetes service-account token. It does not call the Kubernetes API. Its only live data path is the Prometheus HTTP API already used by Grafana.

A separate publisher pod uses the same image. Every 15 seconds it reads the map server's public endpoint through the ClusterIP Service, validates it, and sends the accepted JSON outward.

The topology server and publisher run as separate pods in the K3s monitoring namespace
Figure 1. The map is two small workloads in the existing cluster, not a new VM and not a browser-only drawing.
Inputs

The exact data it reads

For Kubernetes, kube-state-metrics supplies namespaces, nodes, Deployments, StatefulSets, DaemonSets, Jobs, ReplicaSets, pods, containers, Services, Ingresses, and persistent volume claims. Container metrics add CPU and working-set memory. Resource metrics add requests and limits.

For infrastructure, the Proxmox exporter supplies hypervisors, guests, placement, CPU, allocated memory, disks, storage, and uptime. Zabbix-derived metrics add memory observed inside each guest so the map does not mistake allocated RAM for pressure.

Jenkins metrics supply job results, durations, queue depth, wait time, and executor capacity.

Blackbox exporter is a small Prometheus exporter that runs as one pod in the monitoring namespace. It does not scrape anything on its own. Prometheus asks it to probe a target on a schedule, naming the target and the probe type: an HTTP request, a TCP connect, a TLS handshake, a DNS lookup, or an ICMP ping. The exporter performs that probe from inside the cluster and answers with metrics: whether the probe succeeded, how long it took, the HTTP status, and for TLS the expiry of the certificate the service actually presented. The map uses those results as the health of every service endpoint and every network segment, so the color of an endpoint comes from a real connection made seconds ago, not from whether a pod exists.

Additional exporters expose cert-manager expiry, Entra credential expiry, Vault state, Wazuh collector freshness, backup storage, mail delivery, and AI gateway budgets.

Named exporters and metrics flow through Prometheus to the topology builder
Figure 2. Prometheus is the single read interface. The map reuses existing exporters instead of adding credentials for every source system.

How one snapshot is built

The seven-step snapshot build

  1. The server loads the committed skeleton.

    The skeleton defines the operating areas, their order, and the icon and role each one carries. Nothing in the skeleton comes from a live query.

  2. Each builder queries Prometheus.

    Each of the four builders runs its own PromQL instant queries against the Prometheus API. Every query returns a vector of labeled series; the builder reads only labels and current values.

  3. The builders create node objects.

    Each builder turns series into node objects. The node ID is derived from the labels that identify the object, so the same object seen by two queries becomes one node.

  4. Relationships are resolved from labels and ordered rules.

    kube-state-metrics owner labels give namespace to workload to ReplicaSet to pod to container. Proxmox placement labels give host to guest. Jenkins job names are matched against ordered category rules to give category to job.

  5. Detail fields and alerts are attached.

    Details come from the measurement series for that node. Builders create alerts when those measurements produce warning, critical, or missing-signal states; configured skeleton signals can also supply an alert summary.

  6. Health rolls up.

    Each leaf gets one of four states from its own signals, then a recursive child-first pass gives every parent the strongest state of its own signal or any child.

  7. The finished graph is cached and served.

    The graph is cached for 15 seconds. A builder that fails leaves an unknown branch for its area, and the other three builders still render their areas.

BuilderWhat it readsWhat it adds
InfrastructureProxmox host, guest, storage, Zabbix guest-memory, and network-probe seriesHypervisors, guests, templates, storage, firewall, DNS, IPAM, and network segments
Kuberneteskube-state-metrics plus container usage, requests, and limitsNamespaces, nodes, workloads, ReplicaSets, pods, containers, Services, Ingresses, and persistent storage
Core-servicesJenkins, endpoint probes, Entra, certificate, Vault, and Wazuh seriesDelivery pipelines, identity, security, and certificate branches
OperationsBackup, mail, notification, and AI gateway seriesPBS backup and restore, mail delivery, and AI operations

The model resolves IDs and relationships, attaches detail fields and alerts, then rolls child health into each parent. Collection failures are isolated by area. A failed Kubernetes query adds an unknown inventory node without erasing a working Proxmox branch.

Public boundary

The private graph never reaches the browser

The map server rebuilds a public document from an allowlist. Names become role labels, IDs become opaque, exact capacity is reduced, runtime is shortened, and private links and raw alert text are removed.

A whole-document denylist runs after those transformations. The publisher repeats schema, relationship, size, and private-value checks before sending the snapshot to a Cloudflare Worker backed by a Durable Object.

The browser reads only that public object. The Worker caches reads for 30 seconds and refuses a snapshot older than 120 seconds. There is no inbound route from the public page to the K3s Service.

The private graph is projected, checked, published, and read from a Cloudflare Durable Object
Figure 3. Publication is outbound only. A failed candidate leaves the previous accepted snapshot in place.

What clicking actually opens

Ownership

Different systems have different real paths

Kubernetes is the deepest path: namespace, workload, ReplicaSet, pod, then container. That path is reconstructed from kube-state-metrics owner labels, so the map can show desired and ready replicas at the workload and the running image, restarts, CPU, memory, requests, and limits at the container.

Infrastructure follows hypervisor placement into its virtual machines. Delivery starts with a job category and opens into the Jenkins jobs in that category. Certificates split into external, internal, and Linux service certificates before reaching an observed endpoint.

Concrete drill-down paths for Kubernetes, infrastructure, delivery, and certificates
Figure 4. The hierarchy is not one abstract folder tree. Each operating area follows the ownership data its source actually exposes.
Health

The color comes from specific signals

A Kubernetes Deployment compares desired replicas with ready replicas. A container checks readiness, recent restarts, CPU, and memory against requests and limits. A Proxmox host checks exporter availability plus CPU and RAM thresholds. A virtual machine uses Zabbix guest memory when available. A pipeline uses its latest Jenkins result. A service endpoint uses its blackbox probe.

Each leaf receives one of four states. The strongest state rolls into its parent, so a failed container can color its pod, workload, namespace, Kubernetes branch, and the root. Clicking down follows that same chain back to the original signal.

Specific platform signals become leaf health and roll up to their owners
Figure 5. The map does not invent a universal health check. Each object type has its own evidence, then shares the same four-state rollup.
Freshness

Current, degraded, and offline are different claims

The server caches a generated graph for 15 seconds so requests inside one cache window reuse the same Prometheus result instead of rebuilding the graph. The publisher asks for a sanitized graph every 15 seconds, matching that server window so the next eligible snapshot can move outward. The edge may reuse a successful response for 30 seconds, which covers two publisher intervals without hiding the graph's reported age. If the stored graph is more than 120 seconds old, the Worker returns an error so an old healthy graph cannot be presented as current.

The browser stores the last successful graph locally. If the feed is unreachable, it labels that copy OFFLINE · LAST KNOWN and shows its timestamp. With no local copy, it shows no graph.

The 15-second generation and publish cycles feed a 30-second edge cache with a 120-second stale cutoff
Figure 6. Every time boundary is explicit, so an old healthy graph cannot masquerade as current telemetry.

How it runs now

WhenWhat runsWhat a quiet result means
ContinuouslyPrometheus collects exporter measurements and answers the builders' instant queries.The inputs remain available between snapshots; no topology write is happening.
Every 15 seconds, on demandThe first server request after the cache window runs the four builders and stores the finished graph.Requests inside the same window reuse one graph instead of repeating every PromQL query.
Every 15 secondsThe publisher reads the sanitized endpoint, validates the complete document, and sends an accepted snapshot outward.The accepted snapshot remains current and the publisher readiness marker remains present.
Every 30 seconds at the edgeThe public response cache expires and the next read returns the accepted graph with its current age.Readers can share one short-lived response without hiding when the graph was generated.
At 120 secondsThe Worker compares the stored generation time with the current time on every read.A younger graph is served. An older graph receives an error instead of a live label.
On browser fallbackThe browser keeps the last successful graph when a feed request fails.The saved graph is labeled OFFLINE · LAST KNOWN; without a saved graph, nothing is drawn.

What broke

Found2026-08-25
TypeMetadata disclosure
StatusClosed and verified

The short version

The first public feed removed addresses and raw alerts, but some other string fields still carried private identifiers. The map was read only, yet those names did not belong on the public side.

Why it happened

I treated sanitization as a checklist of sensitive fields. That worked until a generated name appeared in a field the checklist did not cover.

What changed

Every public string now passes through the same alias and reduction rules. A committed denylist checks the complete document before publication. A rejected candidate never replaces the previous accepted snapshot.

Verification: the external leak scan returned zero matches on 2026-08-26. The feed remained live with degraded = false.

What I would do differently

I would design the public boundary first, then build the publisher around that contract. The private graph should be treated as untrusted input at the moment it crosses that boundary, even when I own both sides.

One transformation

Run every public string through one policy instead of adding field-specific fixes over time.

One final gate

Check the finished document, because generated names can appear after an earlier field check.

One outside test

Test the public endpoint from outside the lab before calling the projection complete.