---
sidebar_position: 2
title: docker-net
---

# docker-net

A spatial, live-updating map of every container on the host. Containers are grouped by Docker network — bridge networks get their own box, host-mode containers sit at the top level, and isolated (`--network=none`) containers appear in a separate **ISOLATED** box. Multi-network containers are flagged. Compose project/service labels and exposed ports are shown inline.

![docker-net](/img/examples/docker-net.gif)

```sh
# live view, redraws every 2s
yeet run examples/docker-net/render.js

# one snapshot to stdout (pipe-safe)
yeet run examples/docker-net/render.js --once

# raw event stream, one JSON object per line
yeet run examples/docker-net/dump.js
```

<details>
<summary>Source <a href="https://github.com/yeet-src/yeet-scripts/tree/master/examples/docker-net" target="_blank" rel="noopener noreferrer" title="View on GitHub" className="details-gh-link" onClick={(e) => e.stopPropagation()}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58v-2.23c-3.34.73-4.03-1.42-4.03-1.42-.55-1.39-1.34-1.76-1.34-1.76-1.09-.74.08-.73.08-.73 1.2.08 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.49 1 .11-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 3-.4c1.02.005 2.05.138 3 .4 2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.82 1.1.82 2.22v3.29c0 .32.19.69.8.58C20.56 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z"/></svg></a></summary>

**data.js**

```js
// docker-net / data.js
//
// Pure data layer. One-shot fetches docker.list_containers, normalizes,
// groups by network, surfaces compose project/service labels, and flags
// multi-network containers.
//
// Events:
//   { kind: 'snapshot', t, model }
//   { kind: 'error',    t, error }
//
// model:
//   {
//     containers: [Container],
//     byNetwork: { <name>: { containers: [Container] } },
//     projects:  { <name>: { services: [string] } } | null
//   }
//
// Container:
//   { id, name, image, state, networks:[name], multiNet:bool,
//     project, service,
//     ports: [{ ip, privatePort, publicPort, proto, exposed:bool }] }
//
// (`exposed` is true when ip is 0.0.0.0 / ::, i.e. publicly bound.)

const QUERY = `{
  docker {
    list_containers(opts: { all: true }) {
      id
      names
      name
      image
      state
      labels
      ports { ip private_port public_port typ }
      network_settings {
        networks { name network_id aliases }
      }
    }
  }
}`;

function statsQuery(name) {
  /* Docker container names are constrained to [a-zA-Z0-9][a-zA-Z0-9_.-]+
   * — safe to inline. We strip anything else defensively. */
  const safe = String(name).replace(/[^a-zA-Z0-9_.-]/g, "");
  return `{
    docker {
      stats(container_name: "${safe}") {
        cpu_stats {
          cpu_usage { total_usage }
          system_cpu_usage
          online_cpus
        }
        precpu_stats {
          cpu_usage { total_usage }
          system_cpu_usage
        }
        memory_stats { usage limit }
        networks { interface_name rx_bytes tx_bytes }
      }
    }
  }`;
}

function cpuPct(s) {
  const cur = s?.cpu_stats;
  const prev = s?.precpu_stats;
  const cpuD =
    (cur?.cpu_usage?.total_usage ?? 0) - (prev?.cpu_usage?.total_usage ?? 0);
  const sysD =
    (cur?.system_cpu_usage ?? 0) - (prev?.system_cpu_usage ?? 0);
  const cores = cur?.online_cpus ?? 0;
  if (cpuD <= 0 || sysD <= 0 || cores <= 0) return 0;
  return (cpuD / sysD) * cores * 100;
}

function netTotals(s) {
  let rx = 0;
  let tx = 0;
  for (const n of s?.networks ?? []) {
    rx += n.rx_bytes ?? 0;
    tx += n.tx_bytes ?? 0;
  }
  return { rx, tx };
}

const shortId = (id) => (id ? id.slice(0, 12) : "<no-id>");

function nameOf(c) {
  const n = (c.names && c.names[0]) || c.name;
  return n ? n.replace(/^\//, "") : shortId(c.id);
}

function normalizePort(p) {
  const ip = p.ip ?? null;
  const exposed = ip === "0.0.0.0" || ip === "::";
  return {
    ip,
    privatePort: p.private_port,
    publicPort: p.public_port ?? null,
    proto: p.typ ?? "TCP",
    exposed,
  };
}

function normalize(raw) {
  const networks = (raw.network_settings?.networks ?? [])
    .map((n) => n.name)
    .filter(Boolean);
  const labels = raw.labels ?? {};
  return {
    id: raw.id,
    name: nameOf(raw),
    image: raw.image,
    state: raw.state,
    networks,
    multiNet: networks.length > 1,
    project: labels["com.docker.compose.project"] ?? null,
    service: labels["com.docker.compose.service"] ?? null,
    ports: (raw.ports ?? []).map(normalizePort),
  };
}

function buildModel(rawList) {
  const containers = rawList.map(normalize);

  /* Network → containers (a container with N networks appears in N
   * sections; that's intentional — multi-net containers are bridges
   * between networks and need to be visible in each). */
  const byNetwork = {};
  for (const c of containers) {
    const nets = c.networks.length > 0 ? c.networks : ["(no network)"];
    for (const n of nets) {
      if (!byNetwork[n]) byNetwork[n] = { containers: [] };
      byNetwork[n].containers.push(c);
    }
  }

  /* Compose project → services (deduped). */
  let projects = null;
  for (const c of containers) {
    if (!c.project) continue;
    if (!projects) projects = {};
    if (!projects[c.project]) projects[c.project] = { services: [] };
    if (c.service && !projects[c.project].services.includes(c.service)) {
      projects[c.project].services.push(c.service);
    }
  }
  if (projects) {
    for (const p of Object.values(projects)) p.services.sort();
  }

  return { containers, byNetwork, projects };
}

async function fetchStats(container, prevByName) {
  if (container.state !== "RUNNING") return null;

  let resp;
  try {
    resp = await yeet.graph.query(statsQuery(container.name));
  } catch {
    return null;
  }
  if (resp.errors) return null;
  const s = resp.data?.docker?.stats;
  if (!s) return null;

  const now = Date.now();
  const totals = netTotals(s);
  const prev = prevByName.get(container.name);

  let rxBps = null;
  let txBps = null;
  if (prev && now > prev.t) {
    const dt = (now - prev.t) / 1000;
    rxBps = Math.max(0, (totals.rx - prev.rx) / dt);
    txBps = Math.max(0, (totals.tx - prev.tx) / dt);
  }
  prevByName.set(container.name, { t: now, rx: totals.rx, tx: totals.tx });

  return {
    cpuPct: cpuPct(s),
    memBytes: s.memory_stats?.usage ?? null,
    memLimit: s.memory_stats?.limit ?? null,
    rxBps,
    txBps,
    rxTotal: totals.rx,
    txTotal: totals.tx,
  };
}

async function snapshotWith(prevByName) {
  const resp = await yeet.graph.query(QUERY);
  if (resp.errors) throw new Error(resp.errors[0].message);
  const model = buildModel(resp.data?.docker?.list_containers ?? []);

  /* Fan out stats queries for running containers in parallel. */
  const targets = model.containers.filter((c) => c.state === "RUNNING");
  const results = await Promise.all(
    targets.map((c) => fetchStats(c, prevByName)),
  );
  for (let i = 0; i < targets.length; i++) {
    targets[i].stats = results[i];
  }
  /* Drop cache entries for containers that disappeared. */
  const live = new Set(targets.map((c) => c.name));
  for (const k of [...prevByName.keys()]) {
    if (!live.has(k)) prevByName.delete(k);
  }

  return model;
}

export async function snapshot() {
  return snapshotWith(new Map());
}

export function watch(opts, emit) {
  const intervalMs = opts.intervalMs ?? 2000;
  /* Per-container previous network totals, keyed by name. Persists
   * across ticks so we can compute rx/tx rates from deltas. */
  const prevByName = new Map();

  let stopped = false;
  let timer = null;

  async function tick() {
    if (stopped) return;
    try {
      const model = await snapshotWith(prevByName);
      emit({ kind: "snapshot", t: Date.now(), model });
    } catch (e) {
      emit({
        kind: "error",
        t: Date.now(),
        error: String(e?.message ?? e),
      });
    }
  }

  timer = setInterval(tick, intervalMs);
  tick();

  return {
    stop() {
      stopped = true;
      if (timer !== null) clearInterval(timer);
    },
  };
}
```

**render.js**

```js
// docker-net / render.js
//
// Spatial view of docker networking: an outer HOST box contains every
// host-network-mode container plus a sub-box for each bridge network
// living on the host; isolated (`--network=none`) containers render as
// a separate ISOLATED box outside the host (since they have their own
// netns and can't reach anything).
//
// Modes:
//   --once       print one snapshot to stdout and exit (pipe-safe)
//   (default)    live: redraw in alt-screen on each interval
//
// Other flags:
//   --interval 2000   poll cadence (ms) for live mode
//   --width <n>       force outer width (default: terminal width or 100)
//
// Usage:
//   yeet run examples/docker-net/render.js --once
//   yeet run examples/docker-net/render.js --interval 1000

import { watch, snapshot } from "./data.js";

const intervalMs = Number(yeet.args.interval) || 2000;
const once = yeet.args.once === true;
const widthOverride = Number(yeet.args.width) || null;

/* ------------------------------------------------------------------ */
/* ANSI-aware string utilities                                         */
/* ------------------------------------------------------------------ */

const ANSI_RE = /\x1b\[[0-9;]*m/g;

const visibleLen = (s) => s.replace(ANSI_RE, "").length;

function truncateVisible(s, n) {
  let out = "";
  let visible = 0;
  let i = 0;
  while (i < s.length && visible < n) {
    if (s[i] === "\x1b" && s[i + 1] === "[") {
      const end = s.indexOf("m", i);
      if (end < 0) break;
      out += s.slice(i, end + 1);
      i = end + 1;
    } else {
      out += s[i++];
      visible++;
    }
  }
  return out;
}

function padRight(s, n) {
  const len = visibleLen(s);
  if (len >= n) return truncateVisible(s, n);
  return s + " ".repeat(n - len);
}

/* ------------------------------------------------------------------ */
/* Box drawing                                                         */
/* ------------------------------------------------------------------ */

const SINGLE = { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" };
const DOUBLE = { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║" };

/* Wrap content lines in a titled box of given inner width.
 * `lines`: array of strings (may contain ANSI codes).
 * `title`: short label rendered into the top border.
 * Returns a new array of lines representing the boxed content. */
function box({ title, lines, color, double = false, innerWidth }) {
  const c = double ? DOUBLE : SINGLE;
  const paint = color ?? ((x) => x);

  const titleSegment = title ? ` ${title} ` : "";
  const titleVisible = visibleLen(titleSegment);
  const dashesAfterTitle = Math.max(0, innerWidth - 2 - titleVisible);
  const top =
    paint(`${c.tl}${c.h}${c.h}`) +
    paint(titleSegment) +
    paint(c.h.repeat(dashesAfterTitle)) +
    paint(c.tr);

  const bottom = paint(c.bl + c.h.repeat(innerWidth) + c.br);

  const out = [top];
  for (const line of lines) {
    /* Each interior line: vertical bar + 1 space pad + content padded
     * to (innerWidth - 2) + 1 space pad + vertical bar. */
    const content = padRight(line, innerWidth - 2);
    out.push(paint(c.v) + " " + content + " " + paint(c.v));
  }
  out.push(bottom);
  return out;
}

/* ------------------------------------------------------------------ */
/* Container rendering                                                 */
/* ------------------------------------------------------------------ */

const STATE_COLOR = {
  RUNNING: style.green,
  RESTARTING: style.yellow,
  PAUSED: style.yellow,
  CREATED: style.cyan,
  EXITED: style.red,
  DEAD: style.red,
  REMOVING: style.dim,
  EMPTY: style.dim,
};

const stateGlyph = (s) => (STATE_COLOR[s] ?? ((x) => x))("●");

const ts = (t = Date.now()) =>
  new Date(t).toISOString().replace("T", " ").slice(0, 19);

function fmtPort(p) {
  const proto = (p.proto || "TCP").toLowerCase();
  if (p.publicPort == null) return style.dim(`${p.privatePort}/${proto}`);
  if (p.exposed)
    return style.red(`${p.publicPort}→${p.privatePort}/${proto}`);
  const ipPrefix = p.ip ? `${p.ip}:` : "";
  return style.dim(`${ipPrefix}${p.publicPort}→${p.privatePort}/${proto}`);
}

function uniqPortStrings(c) {
  const seen = new Set();
  const out = [];
  for (const p of c.ports) {
    const s = fmtPort(p);
    if (seen.has(s)) continue;
    seen.add(s);
    out.push(s);
  }
  return out;
}

function fmtBytes(n) {
  if (n == null) return "--";
  if (n >= 1024 * 1024 * 1024) return `${(n / 1024 / 1024 / 1024).toFixed(1)}G`;
  if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`;
  if (n >= 1024) return `${(n / 1024).toFixed(1)}K`;
  return `${n}B`;
}

function fmtRate(bps) {
  if (bps == null) return "--/s";
  if (bps < 1) return "0/s";
  if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)}M/s`;
  if (bps >= 1024) return `${(bps / 1024).toFixed(1)}K/s`;
  return `${Math.round(bps)}B/s`;
}

function cpuColor(pct) {
  if (pct == null) return style.dim;
  if (pct >= 80) return style.red;
  if (pct >= 40) return style.yellow;
  return style.dim;
}

function memColor(usage, limit) {
  if (usage == null || !limit) return style.dim;
  const ratio = usage / limit;
  if (ratio >= 0.85) return style.red;
  if (ratio >= 0.6) return style.yellow;
  return style.dim;
}

function statsLine(stats) {
  if (!stats) return null;

  const cpuStr =
    stats.cpuPct != null
      ? cpuColor(stats.cpuPct)(`${stats.cpuPct.toFixed(1)}%`)
      : style.dim("--%");

  const memStr = memColor(stats.memBytes, stats.memLimit)(
    fmtBytes(stats.memBytes),
  );

  const netStr = `${style.dim("↓")}${style.dim(fmtRate(stats.rxBps))} ${style.dim("↑")}${style.dim(fmtRate(stats.txBps))}`;

  return `  ${style.dim("cpu")} ${cpuStr}  ${style.dim("mem")} ${memStr}  ${netStr}`;
}

/* Render a container as a small array of inline lines (no box).
 * If `suppressAlsoOn` is true the "also on: …" annotation is omitted
 * (the renderer is drawing a connector line for this container instead). */
function containerLines(c, currentNet, suppressAlsoOn = false) {
  const head = [
    stateGlyph(c.state),
    style.bold(c.name),
    c.service && c.project
      ? style.dim(`[${c.project}.${c.service}]`)
      : c.service
        ? style.dim(`[${c.service}]`)
        : "",
    c.multiNet ? style.yellow("★") : "",
    style.dim(c.image ?? "?"),
  ]
    .filter(Boolean)
    .join(" ");

  const lines = [head];
  const ports = uniqPortStrings(c);
  if (ports.length > 0) lines.push(`  ${ports.join("  ")}`);
  const sl = statsLine(c.stats);
  if (sl) lines.push(sl);
  if (c.multiNet && !suppressAlsoOn) {
    const others = c.networks.filter((n) => n !== currentNet);
    if (others.length > 0) {
      lines.push(
        `  ${style.yellow("also on:")} ${style.dim(others.join(", "))}`,
      );
    }
  }
  return lines;
}

/* ------------------------------------------------------------------ */
/* Layout                                                              */
/* ------------------------------------------------------------------ */

const SPECIAL_NETS = ["host", "none", "(no network)"];

function bridgeOrder(byNetwork) {
  const all = Object.keys(byNetwork);
  const user = all.filter((n) => !SPECIAL_NETS.includes(n)).sort();
  /* Conventionally render the default `bridge` last among user nets if
   * present (it's the catch-all). */
  const idx = user.indexOf("bridge");
  if (idx >= 0) {
    user.splice(idx, 1);
    user.push("bridge");
  }
  return user;
}

function sortContainers(arr) {
  return arr.slice().sort((a, b) => {
    if ((a.state === "RUNNING") !== (b.state === "RUNNING")) {
      return a.state === "RUNNING" ? -1 : 1;
    }
    return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
  });
}

function renderModel(model, outerWidth) {
  const out = [];

  /* Header. */
  const stamp = ts();
  const cCount = model.containers.length;
  const nCount = Object.keys(model.byNetwork).length;
  out.push(
    `${style.bold("docker-net")}  ${style.dim(`${cCount} container(s) across ${nCount} network(s) — ${stamp}`)}`,
  );
  out.push(
    `${style.dim("legend:")} ${style.red("●")}${style.dim(" port public  ")}${style.dim("●")}${style.dim(" port loopback  ")}${style.yellow("★")}${style.dim(" multi-network bridge")}`,
  );
  if (model.projects) {
    const parts = Object.entries(model.projects).map(
      ([p, info]) =>
        `${style.bold(p)} ${style.dim(`[${info.services.join(", ")}]`)}`,
    );
    out.push(`${style.dim("compose:")} ${parts.join("  ·  ")}`);
  }
  out.push("");

  /* HOST box content. */
  const hostShared =
    (model.byNetwork["host"]?.containers ?? []).filter((c) =>
      c.networks.includes("host"),
    ) ?? [];

  const hostInnerLines = [];

  if (hostShared.length > 0) {
    hostInnerLines.push(
      style.dim(
        "share host's network namespace (host ports are theirs):",
      ),
    );
    for (const c of sortContainers(hostShared)) {
      for (const line of containerLines(c, "host")) {
        hostInnerLines.push("  " + line);
      }
    }
    hostInnerLines.push("");
  }

  const bridges = bridgeOrder(model.byNetwork);

  /* Pick up to 4 multi-network containers (in exactly 2 bridges) to
   * draw as connectors. Anything beyond keeps the "also on:" annotation. */
  const eligibleBridges = new Set(bridges);
  const slots = [];
  const MAX_SLOTS = 4;
  for (const c of model.containers) {
    const inBridges = c.networks.filter((n) => eligibleBridges.has(n));
    if (inBridges.length === 2 && slots.length < MAX_SLOTS) {
      slots.push({ container: c, bridges: inBridges });
    }
  }
  const channelWidth = slots.length === 0 ? 0 : slots.length + 1;
  const connectedIds = new Set(slots.map((s) => s.container.id));

  /* Sub-box width leaves room for the connector channel on the right. */
  const subInner = Math.max(20, outerWidth - 6 - channelWidth);

  if (bridges.length === 0 && hostShared.length === 0) {
    hostInnerLines.push(style.dim("(no containers attached to the host)"));
  }

  /* containerId → { net → absolute hostInnerLines row of header line } */
  const headerRows = new Map();

  for (let i = 0; i < bridges.length; i++) {
    const net = bridges[i];
    const containers = sortContainers(
      model.byNetwork[net].containers,
    );
    const bridgeLines = [];
    const offsets = []; /* {containerId, lineIdx} per container */
    for (const c of containers) {
      const cLines = containerLines(c, net, connectedIds.has(c.id));
      offsets.push({ containerId: c.id, lineIdx: bridgeLines.length });
      for (const line of cLines) bridgeLines.push(line);
      bridgeLines.push("");
    }
    /* Drop trailing blank inside box. */
    if (bridgeLines[bridgeLines.length - 1] === "") bridgeLines.pop();

    const subBoxLines = box({
      title: `${style.cyan(style.bold(net))} ${style.dim(`(${containers.length})`)}`,
      lines: bridgeLines,
      color: style.cyan,
      innerWidth: subInner,
    });
    const startRow = hostInnerLines.length;
    for (const line of subBoxLines) hostInnerLines.push(line);

    /* Header row in hostInnerLines = startRow + 1 (top border) + offset. */
    for (const o of offsets) {
      let m = headerRows.get(o.containerId);
      if (!m) {
        m = {};
        headerRows.set(o.containerId, m);
      }
      m[net] = startRow + 1 + o.lineIdx;
    }

    if (i < bridges.length - 1) hostInnerLines.push("");
  }

  /* Channel post-pass: append connector chars to each hostInner line. */
  if (channelWidth > 0) {
    const slotChars = [];
    for (let r = 0; r < hostInnerLines.length; r++) {
      slotChars.push(new Array(channelWidth).fill(" "));
    }

    const placements = [];
    for (let s = 0; s < slots.length; s++) {
      const m = headerRows.get(slots[s].container.id);
      if (!m) continue;
      const rs = slots[s].bridges
        .map((b) => m[b])
        .filter((r) => r !== undefined);
      if (rs.length !== 2) continue;
      rs.sort((a, b) => a - b);
      placements.push({ slot: s, top: rs[0], bottom: rs[1] });
    }

    /* Pass 1: verticals (intermediate rows). */
    for (const p of placements) {
      const col = p.slot + 1;
      for (let r = p.top + 1; r < p.bottom; r++) {
        if (slotChars[r][col] === " ") slotChars[r][col] = "│";
      }
    }
    /* Pass 2: stubs at top/bottom rows (only into empty cells). */
    for (const p of placements) {
      for (let c = 0; c <= p.slot; c++) {
        if (slotChars[p.top][c] === " ") slotChars[p.top][c] = "─";
        if (slotChars[p.bottom][c] === " ") slotChars[p.bottom][c] = "─";
      }
    }
    /* Pass 3: corners (always overwrite). */
    for (const p of placements) {
      const col = p.slot + 1;
      slotChars[p.top][col] = "┐";
      slotChars[p.bottom][col] = "┘";
    }

    /* Apply: pad each line to (subBox+spacing) width, then append channel. */
    const lineTarget = outerWidth - 4 - channelWidth;
    for (let r = 0; r < hostInnerLines.length; r++) {
      const padded = padRight(hostInnerLines[r], lineTarget);
      const channelStr = slotChars[r].join("");
      const styled = channelStr === " ".repeat(channelWidth)
        ? channelStr
        : style.yellow(channelStr);
      hostInnerLines[r] = padded + styled;
    }
  }

  const hostBox = box({
    title: `${style.bold(style.red("HOST"))} ${style.dim("(network namespace)")}`,
    lines: hostInnerLines,
    color: style.red,
    double: true,
    innerWidth: outerWidth - 2,
  });
  for (const line of hostBox) out.push(line);

  /* ISOLATED box (containers on `--network=none` or with no networks
   * reported) — drawn outside HOST since they have their own netns. */
  const isolated = [
    ...(model.byNetwork["none"]?.containers ?? []),
    ...(model.byNetwork["(no network)"]?.containers ?? []),
  ];
  if (isolated.length > 0) {
    out.push("");
    const innerLines = [];
    innerLines.push(
      style.dim("private netns, no connectivity to host or other containers"),
    );
    for (const c of sortContainers(isolated)) {
      for (const line of containerLines(c, "none")) {
        innerLines.push("  " + line);
      }
    }
    const isoBox = box({
      title: `${style.bold(style.yellow("ISOLATED"))} ${style.dim("(none)")}`,
      lines: innerLines,
      color: style.yellow,
      double: true,
      innerWidth: outerWidth - 2,
    });
    for (const line of isoBox) out.push(line);
  }

  return out;
}

function getOuterWidth() {
  if (widthOverride) return widthOverride;
  let cols = 100;
  try {
    cols = tty.size().cols;
  } catch {
    /* keep default */
  }
  return Math.max(40, Math.min(cols, 140));
}

/* ------------------------------------------------------------------ */
/* Entry                                                               */
/* ------------------------------------------------------------------ */

if (once) {
  snapshot()
    .then((model) => {
      const w = getOuterWidth();
      for (const line of renderModel(model, w)) console.log(line);
    })
    .catch((e) => {
      console.error(`docker-net: ${e?.message ?? e}`);
    });
} else {
  try {
    tty.write("");
  } catch {
    console.error(
      "docker-net/render.js needs a PTY in live mode. Use --once or dump.js.",
    );
    yeet.exit();
  }

  tty.alt();
  tty.hideCursor();
  tty.clear();

  /* Flicker-free repaint:
   * - Build the whole frame as one string (cursor-home, content, erase-
   *   to-end-of-line per row).
   * - If the new frame is shorter than the previous one, erase from the
   *   cursor to end of screen so trailing leftovers are wiped.
   * - Send it all in a single tty.write — the terminal swaps the frame
   *   atomically, no blank intermediate. */
  const CURSOR_HOME = "\x1b[H";
  const ERASE_TO_EOL = "\x1b[K";
  const ERASE_TO_EOS = "\x1b[J";

  let lastLineCount = 0;

  function paint(lines) {
    let buf = CURSOR_HOME;
    for (const line of lines) buf += line + ERASE_TO_EOL + "\n";
    if (lines.length < lastLineCount) buf += ERASE_TO_EOS;
    tty.write(buf);
    lastLineCount = lines.length;
  }

  watch({ intervalMs }, (ev) => {
    if (ev.kind === "error") {
      paint([style.red(`error: ${ev.error}`)]);
      return;
    }
    if (ev.kind !== "snapshot") return;
    const w = getOuterWidth();
    paint(renderModel(ev.model, w));
  });
}
```

</details>

## Try it with the demo stack

A `demo.sh` is included to spin up a small multi-network stack:

```sh
./examples/docker-net/demo.sh up
yeet run examples/docker-net/render.js
./examples/docker-net/demo.sh down
```

## How it works

`data.js` queries `docker.list_containers` from the system graph and normalizes the result. `render.js` is pure presentation — swap in a different layout, grouping logic, or a Slack alert without touching the data layer.
