Changelog
All notable changes to netviz are recorded here. The format follows
Keep a Changelog and the versioning follows
Semantic Versioning, with the 0.x caveats spelled
out in docs/releasing.md.
What belongs in an entry is what a user would notice: a flag, a schema field, a rule, an
output format, an exit code, a diagram that comes out different. Refactors, test additions
and internal performance work are only listed when they change one of those. The rest is in
git log.
Every release is cut from the section named after it, and the release workflow refuses to
publish a version whose section is missing or empty — see
tools/release.py.
Unreleased
Added
-
An Ansible integration, and a query language that can be templated safely. netviz now ships an Ansible collection,
netviz.netviz, holding a dynamic inventory plugin, a lookup plugin and five filters. The inventory plugin builds the same documentnetviz export ansible-inventorywrites — one implementation, so a checked-in inventory file and the plugin that replaces it cannot disagree about who is a server — and adds host variables and groups that are queries. The lookup is the piece a file cannot have: a template asks the network for the address it is about to write down.[Network] {% for address in query('netviz.netviz.query', 'select (device filter .fqn = $fqn).addresses.address') %} Address={{ address }} {% endfor %}$fqnis the element the host being configured came from, bound automatically along with$host,$name,$namespaceand$kind. The collection also ships a playbook,netviz.netviz.systemd_network, that renders a systemd-networkd unit per addressed interface, so the whole loop is runnable in one command.docs/ansible.mdis the guide. -
Query parameters:
$namein a relational query, andnetviz query --param. A query may leave a hole for a value and take it from the caller, which is what a generated query needs — one written by a script, a CI job or a configuration template.$ netviz query 'select (device filter .name = $host).addresses.address' --param host=rtr-home $ netviz query 'select vlan { id, name } filter .id in $ids' --param 'ids:=[10, 20]'A parameter is a token rather than text, so nothing in the value can change what the query asks — a device name with an apostrophe in it is a device name. It is also typed, by the value that was passed, and the type is checked against the query before an inventory is read:
.name = $idwith$idbound to a number is refused where it is written. A list binds to a set, sofilter .name in $namestakes one name or ten.docs/nql.mdhas the reasoning. -
netviz ansible—pathprints the collections path holding the shipped collection (export ANSIBLE_COLLECTIONS_PATH="$(netviz ansible path)"and nothing is copied),installcopies it into a control node's own collections path, andinventoryprints the document the plugin builds — for reading, for diffing, and for use as a dynamic inventory script by a control node that would rather not load a plugin. -
A relational query language, and
netviz querynow answers two. A query that begins withselectorwithwalks the schema, joins by following links, and returns the shape it is asked for — a value, an object, or an array of nested objects. Anything else is the selector, unchanged.$ netviz query 'select interface { fqn, parent: { fqn, kind }, addresses: { address } } filter exists .addresses' $ netviz query "select (server filter .name = 'srv-01').addresses.address" $ netviz query 'select { devices := count(device), subnets := count(subnet) }' -F jsonThe language is EdgeQL-shaped rather than SQL- or Cypher-shaped, and
docs/nql.mdsays why: netviz's inventory is already a typed object graph, so path navigation replaces joins and{ … }shapes replacecollect(). Two functions are borrowed from Cypher's traversal —neighborsandreachable— for the questions no fixed number of named steps can answer.Twenty-four types are queryable: every declared kind of §3, the sub-objects a device holds (
interface,address,vlan,netns,zone,route), and four derived ones (subnet,broadcast_domain,link,rack) that come from the same functions a diagram is drawn with, so a query and a picture cannot disagree. Every name is checked against the schema before an inventory is read, so a misspelling is a diagnostic with a suggestion rather than an empty answer.Whether a field comes back as an array is decided by the schema's cardinality and not by how many values happened to match, so a device with one interface still projects a one-element array and a script never has to sniff.
-
netviz query --describe [TYPE]— the relational language's grammar, types and functions, or one type's members with their cardinalities. Generated from the same table the parser checks against, so the help cannot drift from what is accepted. -
netviz query -F table|json|yaml|csvfor a relational answer.tableandcsvflatten a nested field into one cell;jsonandyamlcarry it whole.--countcomposes with all four, and the command still exits 1 when nothing matched. -
An
ipamview: the address plan as a diagram, at--layer ipam. Everythingnetviz ipamprints as a table is now also a picture — one box per prefix, with a utilisation bar, the counts, and the free blocks left in it. The devices are left off, which is the whole difference froml3: a plan with the hosts on it answers "who is in this subnet", and burying "how much room is left and where" is the one thing an address plan must not do.A prefix is drawn inside the block it was carved out of, joined by a dashed
allocationline, per routing instance — a VRF is an address space of its own, so containment never crosses one. Nothing is invented except the one case the plan itself implies: a supernet whose halves are both declared is drawn and markedsummarised, exactly asnetviz ipam --aggregatereports it. A/16the inventory has one/24of is not drawn, and two host routes are never summarised into the/31they happen to be adjacent in.The layer is offered everywhere every other layer is: the
netviz webswitcher and its command palette,render -f htmlbehind the same layer switcher,-f dot,-f svg,-f png,-f mermaid,export drawio --view ipam,netviz report --layer ipam, stored geometry (views: [ipam]) and annotations.-f jsongives every box anipamobject — the utilisation rownetviz ipam --format jsonprints, plus the parent, the children and the free blocks — beside thesubnetobject it already carried, and the editor's info box and the HTML page's detail panel show the same numbers.--name,--namespaceand--vlannarrow the plan through the addresses in each prefix, so naming a server shows the blocks it is addressed in.Written up in
docs/rendering.md.
0.0.3 - 2026-08-18
Changed
-
Every environment netviz builds now comes out of a committed
uv.lock. A contributor's checkout, all six CI jobs, the nightly property run, the published demo site, the container image and the release's ownbuild/twine/hatchling are installed at exact, recorded versions instead of resolved fresh from dependency ranges. Nothing a user of the package notices changes — the wheel's declared ranges are untouched — but a red CI run is now a statement about a commit rather than about what the index published that hour, and a nightly counterexample can be reproduced a month later.Getting a checkout running is now
uv sync --extra dev(seeCONTRIBUTING.md);--extra browser,--extra siteand--group iconsare the other three environments.uv lock --checkruns as CI's first job and as a pre-commit hook, so editing dependencies without re-locking is caught in seconds rather than in six matrix entries.Two places deliberately carry no lockfile, and both say so in the file: the three composite actions, which install a released netviz into other people's repositories where our pins would be an imposition, and the release's
verifyjob, whose whole purpose is to prove the published artefact's own ranges still resolve in an environment that has never seen this repository.
Fixed
-
The committed artefacts still reported version 0.0.1, having been left behind by the 0.0.2 bump: the documented transcripts for
netviz export,netviz report --format jsonandnetviz test; the as-built example report underdocs/example-report/; the draw.io export fixtures; and threenetviz diff --format jsongolden files. Fourteen tests were failing onmainbecause of it. -
Six more documents kept saying 0.0.1 with nothing to catch them.
docs/home-lab.htmlis deliberately not compared byte for byte — its drawing depends on the installed Graphviz — and the transcripts indocs/commands/cache.mdanddocs/commands/version.mdare markednorunbecause every value in them belongs to the reader's machine, so no test re-derived any of the three. Thetoolblock in the JSON examples indocs/ci.md,docs/commands/impact.mdanddocs/commands/review.mdis hand-written and was never re-derived either. All six now name the running version, andtests/test_release.pychecks every family of committed artefact that prints a version againstpyproject.toml, so the next bump that forgets one fails on the commit rather than at the tag. The full list of regenerators is indocs/releasing.md, and a test keeps that list and the checked families in step.
0.0.2 - 2026-08-17
A release-plumbing release. netviz itself is unchanged from 0.0.1 — no schema field, flag,
rule, output format or exit code differs — but 0.0.1 never reached PyPI, so this is the
first version installable with pip install netviz.
Fixed
- The release could not upload a wheel built by a current hatchling. hatchling began
emitting
Metadata-Version: 2.5, which PyPI accepts, but the release pinnedpypa/gh-action-pypi-publishtov1.12.4— a Docker action, so the pin also froze thetwineandpackaginginside it at versions predating 2.5. The upload rejected its own artefacts asInvalidDistributionafter the entire gate had passed. The pin is nowv1.14.2, and thebuildjob additionally checksdist/with the twine from that very image, so a validator this repository does not install can no longer disagree with the one it does.
0.0.1 - 2026-08-16
First release. netviz reads a folder tree of YAML documents describing a network, checks that the documents agree with each other, and renders the result.
Nothing has been published before it, so this section is the whole of the project rather than a delta against something already installed, and it is laid out that way: each heading lists what the foundation was built with, and then the work done on top of it. Entries in the second half are worded as changes because that is what they were to the tree; to a first-time installer they are simply how netviz behaves.
Added
-
The inventory format.
apiVersion: netviz.dev/v1alpha1documents in nine kinds —switch,router,hub,computer,server,adapter,cable,tunnelandpatchpanel— discovered recursively under a root folder, where the folder a document sits in becomes its namespace. Field names and value spaces follow RFC 8343 (ietf-interfaces), RFC 8344 (ietf-ip) and the IEEE 802.1Q bridge model. Normative specification indocs/schema.md. -
Interfaces, addressing and VLANs. Physical and logical interfaces with MAC addresses, IPv4/IPv6 addresses and prefixes, DHCP,
access/trunk/routedport modes, native and tagged VLANs, LAGs, bridges and sub-interfaces. Interface ranges (ethernet-1/1..1/48) and reusable device templates so a 48-port switch is not 48 blocks of YAML. -
Wireless detail. SSIDs, bands, channels and widths, and the BSS-to-SSID mapping, with station associations drawn as links.
-
Routing. VRFs, static routes and protocol adjacencies (OSPF, BGP, IS-IS), following RFC 8349, plus a
routinglayer that draws them. -
Tunnels as a first-class kind. WireGuard, IPsec, OpenVPN, PPTP, GRE, L2TP and VXLAN, including tunnels carried inside other tunnels.
-
Passive plant. Patch panels with derived ports, racks, rack units and a rack-elevation view.
-
netviz validate— three passes (schema, reference resolution, semantics) over a catalogue of graded rules, each with anNV-*alias, a documented reason and a fix.--strictpromotes warnings,--disablesilences by id or alias, and the machine-readable forms are--output-format json|sarif|githubfor pipelines, code scanning and inline annotations. Exit codes:0clean,1findings,2usage. -
netviz render— seven layers (l1,l2,l3,overlay,routing,rack,logical) tosvg,png,pdf,dot,mermaid,jsonand a self-contained interactivehtmlpage. Filters by namespace, VLAN, kind and neighbourhood; namespace collapsing and link bundling for inventories too large to read whole;--icons ciscofor device pictures. SVG output carries per-element tooltips,--link-templatelinks back to the YAML, and stable element ids for deep-linking. -
netviz webandnetviz watch— the inventory edited in one browser pane and drawn in the other, and a live preview that re-renders on every save. -
netviz path— trace how two elements reach each other, at any layer, with the answer optionally drawn. -
netviz ipam— subnet utilisation, free space, the next free block, aggregation and overlap detection. -
netviz export— hosts file, DNS zone, DHCP reservations, Ansible inventory and Prometheus targets, generated from the same documents. -
netviz import— bootstrap a first inventory from LLDP,ip -j addr,showcommand output or a cabling CSV. -
netviz drift— the declared inventory compared against what the live network reports, with per-element coverage so an unchecked device is not silently counted as agreeing. -
netviz fmt— one canonical form for inventory YAML, with--checkand--diff. -
netviz list,show,rules,schema,config— interrogate an inventory, the rule catalogue and the resolved configuration from the shell. -
netviz init— scaffold a small, valid inventory, including the JSON Schema and the editor wiring. -
netviz completion— completion scripts for bash, zsh, fish and PowerShell, with completion of element names, namespaces, kinds, layers, formats, profiles and rule ids. -
netviz version— the netviz, Python and Graphviz versions in use, the selected YAML parser and the resolved dependency versions;--jsonfor pasting into a bug report.netviz --versionprints the same text. -
JSON Schema output (
netviz schema) so an editor underlines a typo'd key as it is typed, checked intoschema/and wired up bynetviz init. -
netviz.toml— per-inventory render defaults and named profiles, so the flags a diagram needs live next to the inventory instead of in shell history. -
CI integrations — a
netviz-validatecomposite GitHub Action, threepre-commithooks (netviz-validate,netviz-fmt,netviz-fmt-check) and a documented GitLab recipe. -
Published artefacts.
pip install netviz(alsopipxanduv tool install), from PyPI via Trusted Publishing, and alinux/amd64+linux/arm64container image atghcr.io/blechschmidt/netvizthat already has Graphviz in it and runs unprivileged on a read-only root filesystem. The wheel, the sdist and the image carry build provenance attestations, and each release attaches an SBOM for the wheel's dependency closure and one for the image.docs/releasing.mdrecords what the version number promises and which surfaces it promises it about. -
A compose file for the three ways the tool is used in a container — one command at a time, as a live preview, and as the browser editor.
-
Windows and macOS support, tested in CI. Graphviz installed without landing on
PATHis found in the documented install locations, andNETVIZ_DOTnames the binary outright. -
Icons are a switch in the editor, not a flag you restart for.
netviz webcould draw devices as pictures only if--iconshad been passed when the server started, which made "what does this look like with icons on" a question you had to stop and restart to answer — while looking at the diagram that prompted it. The header now has an icons box,Alt-Ktoggles it, and it is on the canvas context menu beside the layer.The split is the point. Which themes exist stays the command line's to say, because a theme is a directory of images on the machine running the server:
--icons DIRputs your own on the list, and the bundledciscoset is always on it. Whether a drawing uses one is now the browser's, because that is a fact about the picture and about nobody's filesystem./api/statesays what is on offer,?icons=ciscoand?icons=noneare view parameters like every other, and a request naming anything else — a path above all — is refused with the list it could have named.--iconsnow sets where the switch starts: a session opened with it can still turn icons off, and one opened without can still turn them on. -
netviz reportdocuments network namespaces. A device page is where an operator looks to answer "what is this machine", and sincespec.netnslanded it showed none of the stacks a machine runs. It now carries a Network namespaces section: the namespace tree, with each declared namespace indented under the one it was created from; the interfaces homed in each stack and the addresses they carry; the veth pairs, one row per end so a pair is named from both sides of the boundary; and the static routes and policy rules a declared namespace holds. The interface table gains aNETNScolumn besideVRF, because the two compose rather than compete.Both are conditional. The section is drawn only on a device that declares
spec.netnsor a veth pair, and the column only when an interface on that page is in a stack other than the initial one — so a report of an inventory that uses neither is byte-for-byte what it was before. The section and the routing section link to each other and neither restates the other's half: a VRF is described once, in Routing, and named in the namespace section only to say which stack it is an instance of. Closes follow-up 22. -
netviz query, and--selecton six more commands: one selector language. Element selection was spelled a different way in every place that needed it.filter_graphtook fixed keyword predicates;netviz listhad its own flags; akind: testsuiteassertion had its own matchers; the editor's search box was a substring match. None of the four could express the questions operators actually ask — every access switch in site north with no uplink, every interface addressed in 10.20.0.0/16 that is not in a VRF, everything within two hops of the firewall — and no two of them could express quite the same subset of the ones they could.A query is a predicate over the resolved model (
kind = switch,label.role = access,address in 10.20.0.0/16,has vrf), existential scopes over sub-objects (interface[address in 10.20.0.0/16 and not has vrf],link[peer-kind = router]), and bounded graph traversal (neighbors of X,within N hops of X,reachable from X), combined withand,or,notand parentheses. It is deliberately nothing else: no binding, no arithmetic, no call, and no recursion beyond the finite tree the parser builds — so every query terminates and none of them can change anything, which is what makes the same expression safe in a pre-commit hook, in a browser on every keystroke, and inside an assertion nobody will read again for a year.It is used in four places and implemented in one (
netviz/query/):netviz query '<expr>'prints the matching elements, with--json,--count,--layer,--print interfaces|linksfor the sub-objects a scope matched, and--explainfor the grammar and the whole attribute vocabulary. It exits 1 when nothing matched, so a query is a check.--select '<expr>'onrender,watch,show,list,exportandreport, layered overfilter_graphrather than replacing its internals. The existing filter flags keep working and are documented as sugar for the equivalent query —--kind Kiskind = K,--namespace NSisnamespace under NS,--neighbors-of N --depth Diswithin D hops of (fqn = N or name = N)— with a test that each selects exactly what its rendering does.netviz show --selectprints every match instead of one named element.assert: queryin akind: testsuitedocument, andquery:as an alternative toselect:on every assertion that takes one. With no bound the claim is that the query matches nothing, which is how a network invariant is written — no device is missing a management address is one line, and the counterexamples are the failure report.- The editor's search box and command palette, which highlight the matches live, filter the drawing when asked, and turn the answer into the selection on Enter — so a query feeds straight into the existing bulk edit and alignment operations.
A parse error points at the offending column with a caret, in the loader's own diagnostic shape:
query:1:18: 'vlna' is not an attribute of element kind = swtch and vlna = 99 ^^^^ help: did you mean 'vlan'?docs/query.mdis the grammar reference, the attribute tables and a cookbook of ten worked queries againstexamples/campus;docs/commands/query.mdis the command. There is a fuzz target for the parser beside the loader's, and property tests that a query and its negation partition the inventory. -
Firewalls and firewall policy:
kind: firewall,spec.zonesandspec.firewall(§24). Everywhere else in the schema a device forwards, and every answer it could produce was some version of "and then the packet goes there". A firewall is the box whose answer is often "and then it does not", and there was nowhere to write that down — including in §16.7, which told the reader in as many words to mark a packet in the firewall and matchfwmark, an instruction to use a thing the schema could not describe.spec.zones[]divides a device's interfaces into named regions, and policy is written between zones rather than between interfaces so that a rule survives a port being renamed or moved to a LAG. An interface is in at most one zone (NV-B003), which is the defining property of a zone in every implementation and what makesfrom lana statement about a packet rather than a question.local— the traffic that terminates on the machine — is nameable without being declared and cannot be declared (NV-B001), and it is also what turns the two zone fields into a hook:to: localis input,from: localis output, two real zones are forward. The schema never asks which chain a rule is in.spec.firewall.rules[]is the filter policy: walked from the lowestpriorityupwards, first terminal match deciding.accept,dropandrejectdecide the packet;markandlogdo something to it and let the walk continue, which is the whole reason those two exist. Selectors aresrc,dst,protocol,src_ports,dst_ports,ct_state,iifandoif, optionally inverted. The three chain defaults are stated rather than implied and are deny/deny/permit — the failure mode is a service that does not work rather than a network that is open.spec.firewall.nat[]carriessnat,masquerade,dnatandredirect, apart from the filter rules because a packet is translated and filtered.action: markcloses §16.7's loop, and the two halves are checked against each other. A mark is local to the machine — metadata inside one kernel, gone when the packet leaves — so the box that routes by a mark is the box that has to set it. A mark written that nothing reads isW152; one read that nothing writes isW153. Each is silent on its own and wrong only together. Three more:W150, a zone holding no interface, counting the rules that can therefore never match;W151, an interface outside a partition the device does declare;W154, a rule numbered above the one that closes its chain — which reads the zone pair too, solan -> wan acceptcloses only what crosses between those two.kind: firewallis the thirteenth element kind, structurally arouterand deliberately so: filtering is a function, not a box, sospec.zonesandspec.firewallare available on every layer-3 kind. What the kind buys is the picture and the vocabulary — a trapezium in red, a brick wall in the cisco icon theme, and a reader who can see at a glance which boxes the policy is expected to be on. -
netviz render --layer security. The one view whose edges are decisions rather than paths. Nodes are zones, framed by the device that declares them, withlocalandanyminted where the policy names them. Edges are zone pairs, directed, because lan to wan is a different statement from wan to lan and a picture that merged them would have merged the one distinction a firewall exists to make. Green where the pair is open, red where it is closed, dashed amber where it is conditional. The rules are on the label up to three of them and on the tooltip in full, in the order the device walks them.--name,--namespaceand--kindreach a zone through the device it is on, since nothing at this layer stands for the box, so--name fw-edgedraws one firewall's policy. -
netviz export nftables. The seventh configuration dialect, and the first that writes what a device refuses rather than how it is wired. Oneetc/nftables.conf: atable inet netvizwith asetper zone, the three base chains carrying their stated policies, and NAT chains only where a translation needs them.destroy tableprecedes it, so applying the file replaces netviz's table and leaves everything else on the box exactly as it was. Nothing is inferred — no connection-tracking rule the document did not ask for, no loopback exemption, no rate limit — andinvertis refused rather than written matching the opposite of what the inventory states.netviz driftreads the dialect back, and grants it no coverage: what a ruleset sees is not what drift compares. The generated file is checked by a realnft --checkin the suite, which is the one gate here that is not netviz reading its own output. -
Policy-based routing:
spec.route_tables,spec.routes[].tableandspec.routing_policy(§16.2, §16.4). Everywhere else in §16 a device answers one question about a packet — which route in the table matches its destination. That is right for most boxes and wrong for every edge router anyone has actually built: a guest VLAN out the cheap uplink, a marked flow down the tunnel, a lab prefix that must not reach management. Each of those is a decision about which table to route by, made before the destination is looked at, and the schema had nowhere to put it.spec.route_tables[]declares a table — a name and a number — andspec.routes[].tableplaces a route in one. The three tables every stack is born with (main,local,default) are nameable without being declared and cannot be declared, by either name or number (NV-F015). A VRF is a routing table too, sotable:resolves againstspec.vrfsas readily (NV-F019) — butvrfandtableon one route are a contradiction rather than a refinement (NV-F018).spec.routing_policy[]is the database itself: an ordered list of rules, walked from the lowestpriorityupwards, first match deciding — the shape RFC 1812 §5.2.4.3 describes and every implementation implements. A rule selects onsrc,dst,fwmark,iif,oifanddscp, optionally inverted, and does one of five things:lookupa table,blackhole,unreachable,prohibit, orgotoanother priority.priorityis the rule's identity as well as its position, so it is unique per device per family (NV-F020); a rule that states nofamilyand no prefix is installed in both, which is what typingip ruleandip -6 ruledoes by hand.Layer 4 is deliberately not a selector. No
sport, nodport, no protocol:ip rulegrew them late and nothing else agrees on them. The portable way to route by port, by user or by application is to mark the packet where marking belongs and matchfwmarkhere.Three rules report the ways a database goes wrong without going invalid.
W147— a rule looks up a declared table no route is placed in, so the diverted traffic falls through and goes exactly where it would have gone anyway.W148— a declared table no rule looks up, so its routes are never consulted.W149— a rule numbered above the catch-all that terminates its family's database, so it can never run. -
The routing view, the routes script and networkd carry it.
--layer routinglabels a router with its rule count and lists its tables, its routes and its database — in priority order, which is the order the device walks it — on the tooltip and in-f json.netviz export routeswrites the rules beside the routes they select, routes first so no rule ever diverts traffic into a table that is still empty; each rule is adelof its priority followed by anadd, which is howip ruleis made idempotent. A table is written by number with its name in a trailing comment, since a name resolves only through/etc/iproute2/rt_tablesand this script does not edit that file.netviz export networkdwrites the database as[RoutingPolicyRule]sections;netviz export interfacesprojects it asroute-tableandpolicystanzas; frr, netplan and ifupdown name it in their manifests with the emitter that does write it. -
A machine is no longer one network stack:
spec.netns,interfaces[].netnsand veth pairs (§23). Everywhere else in the schema a host has one set of interface names, one address space and one routing table, and one box on a diagram holds all of it. That is exactly right for a switch and a laptop, and it stops being right the moment the host is a container host — a server running twelve containers has twelve of each, and an inventory that records one of them has recorded one twelfth of the truth.spec.netns[]declares the namespaces a machine runs;interfaces[].netnsputs an interface in one; an interface that names none is in the machine's initial namespace, which no document declares because every machine has it.parentnests one namespace inside another, to any depth — a namespace is created from inside exactly one other, so the nesting is a tree and the chain always ends at the initial namespace.A namespace is not a VRF and the two compose. A VRF partitions the routing table of one stack; a namespace is a second stack, so it partitions the interface names, the addresses, the sockets and the routes at once. An interface may name both.
-
A veth pair is two
ethernetinterfaces naming each other, and deliberately not a new interface type. A veth end isianaift:ethernetCsmacdin every respect the rest of the schema cares about — it has a MAC, it carries 802.3 frames, it can be a bridge port, it can carry a VLAN sub-interface — so a type of its own would mean restating §6.2 for a port that behaves identically. What it does not have is a socket, andinterfaces[].peeris what says so: it names the other end, and the other end has to name it back (NV-N023), because a veth pair is created as a pair and destroyed as a pair and a document describing half of one describes something the kernel cannot be asked for.Four rules follow.
E049refuses a cable on a veth end —E012cannot catch it, since by type a veth end is exactly a cabled port.E050refuses a bridge or lag that aggregates a member in another namespace: one datapath belongs to one stack, and moving a port into a namespace is precisely the operation that takes it out of the aggregate.W146reports a declared namespace nothing is in.I005reports a pair with both ends in one namespace — legal, and usually anetnswritten on one end and forgotten on the other.I002stops reporting veth ends as spare ports, which it would otherwise do to every one of them. -
--layer netns, the one view that draws below the machine. The element node stays and stands for the initial namespace — it keeps its kind, its icon, its link to the document and its place in a stored arrangement, because it is still the machine — and each declared namespace becomes a rounded box beside it, every box of one machine framed together. Solid cyan lines are veth pairs: the crossing itself, which no other layer can draw because at layer 1 both ends are inside one box. Dotted lines are nesting. Cables are kept and re-pointed at the namespace holding the interface they land on, which answers the question the view exists for — how does the stack inside this container reach the wire? A machine with one stack is drawn only as context for one that has more. -
netviz export interfacescarries all of it, and the four dialects that cannot refuse. The neutral dialect grows anetnsstanza and two interface attributes, so a machine's namespaces and pairs survive a round trip throughnetviz importand are compared bynetviz drift.netplan,networkd,ifupdownandfrrwrite nothing for a device that declares either: those files configure the stack they are applied in, and a netplan file listing a container's interface puts the container's address on the host. The refusal names every field and points atexport interfaces. -
examples/containers/, the sixth example inventory: two container hosts running five namespaces, one nested inside another, joined by four veth pairs — one set bridged onto a host bridge, one set routed over a/30. It validates clean, like the other five. -
A delete now takes everything that cannot outlive it — and the editor stops asking for a flag it was always going to be given. "Cascade" used to mean the cables. It now means the whole of what a deleted element leaves behind, in three layers:
- Elements, as before: a link dies with either end, a tunnel with the tunnel it runs over, transitively.
- Annotations (§21), by §21's own coherence rules rather than a table. A note anchored
to the deleted element and placed keeps its text and loses only its anchor; a note that
is only anchored cannot be drawn without it, so it is a dependent — named in the refusal,
removed by
--cascade. An area drops the doomed members and goes only if that would leave it with no members, no selector and no rectangle. In one line: an annotation is removed exactly when clearing its references would leave a document the loader refuses. - Geometry (§18), which is never a dependency and is therefore never asked about.
Deleting one switch out of an arranged home lab used to hand back a tree carrying eight
new
W138warnings — the switch's position in two views, the waypoints of each of its five cables, and the box round the namespace it emptied — and told you to runnetviz layout --prune. It now hands back the tree it found, minus the switch. This is deliberately not--prune, which drops every key the current drawing lacks and would throw away the position of a device merely filtered out of the view.
netviz edit disconnectgrows--cascadefor the same reason a delete has one, and the waypoints of a dropped cable go either way. Two bugs found on the way are under Fixed. -
The editor cascades, and says what that costs before it does it. On the command line a cabled switch is refused and you are told to pass
--cascade. On a canvas that is theatre: somebody who dragged a box to the bin has said what they want. SoDeletealways cascades, and what it owes you instead is the truth — once, before the fact, and not read off the picture.The new read-only
GET /api/cascadeasksnetviz.editfor the set it will actually remove, so the confirmation names what a diagram cannot show you: the tunnel three levels up that runs over a cable that runs to the switch, the note anchored to it in a view you are not looking at, the group that lists it as a member, and the layout entries that placed all of it — each with the reason it goes. The multi-select confirmation used to guess this from the drawn edges, which got the cables right and everything else wrong.A delete that takes nothing but what you named does not ask at all, because a confirmation that always appears is one that stops being read. And the whole of it is one entry in the undo stack: elements, annotations and geometry come back together, byte for byte, on one
Ctrl-Z. -
netviz review, and a pull-request bot built on it. A green check answers "does this branch validate?" A reviewer wants "what does this change do, and what did it break that was not already broken?" — and neither half of that is in a check mark.netviz review --from origin/mainwrites both, as one Markdown document: a verdict line, a table of what is added, changed, renamed and removed grouped by element kind, the findings the change introduced, and the change drawn.Only new problems fail it. The head's diagnostics are measured against the base's by the same fingerprint
-F sarifputs on each result — the rule, the file, the element, the pointer and the message, deliberately not the line — so a repository carrying a legacy warning adopts the check green and the next warning turns it red.--strictand--disableapply to both sides, so a rule silenced in the change itself reads as nothing changing rather than as a wave of fixes.Three side documents come out of the same load and the same validation, so nothing downstream can disagree with the comment:
--plan-out(the changesetnetviz plan --jsonwrites),--sarif-out(for a code-scanning upload) and--summary-out(the verdict and the counts, for a workflow step that gates on them without parsing prose).A base that has no inventory at all, and a base that does not load, are both reviewed rather than refused: the first pull request a repository ever sees, and a
mainthat is already broken, are exactly the cases a bot has to survive to be adopted. -
.github/actions/netviz-reviewand.github/workflows/netviz-review.yml. The action draws the diff to SVG and PNG, produces the changeset and the SARIF, and writes the comment body. The reusable workflow posts it as one sticky comment, edited in place on every push rather than added to, uploads the bundle as an artifact and the findings to code scanning, and fails only on an error the base did not have.docs/ci.mdhas the input tables, the shape of the comment, and why the workflow usespull_requestand neverpull_request_target— on a fork's pull request it degrades to the job summary rather than asking for a token that cannot safely exist.The diagram appears three ways because a GitHub comment is sanitised and neither an inline
<svg>nor adata:image survives it: a Mermaid summary of the changeset that always renders, an<img>for anything the caller published to a URL, and a link to the uploaded artifact.This repository reviews its own
examples/with it —.github/workflows/review.yml. -
netviz converge plan: drift, joined to the configuration emitters, as an ordered per-device remediation.netviz driftsaid how the live network differs from the inventory andnetviz export configsaid what a device would run if it agreed; nothing joined them. This does. It takes the same capturesdrifttakes and produces, per device, the minimal ordered set of changes that would move it from what the capture found to what the inventory declares — each one carrying the drift finding that asked for it, asafeordisruptiveclassification, its prerequisites, the commands that perform it and the commands that undo it.netviz never applies any of it, and there is no flag that adds a transport. The command reads capture files and writes a plan and per-device
.txtscripts; a person runs them. The security surface stays "reads files, writes files". The plan type is shaped so a transport could consume it later — every change has a stable id, prerequisites, a risk and an inverse — but that would be a separate program.A plan touching the path a device is managed on, or shutting or deleting any interface, is refused whole unless
--allow-disruptive, and the refusal names every offending change rather than the first. The management interface is the onenetviz exportalready picks foransible_hostand a scrape target, plus everything it is stacked on and the VLAN it lives in.Changes come out in a dependency order — VLANs before the ports that carry them, parents before what is stacked on them, addresses before routing, every addition before every removal and removals in the mirror order — and are grouped into maintenance batches by the existing
netviz impactengine, so two devices share a window only when neither is in the other's blast radius.--dialectpicksinterfaces(netviz's own imperative grammar, one line per change, and the default) or one ofnetplan,networkd,ifupdown,frrandwireguard, whose remediation is genuinely the generated file plus a reload — computed by running the existing emitters over both the declared and the observed state and keeping only the files that differ.--format text|json|markdown,-o DIRfor the scripts,--rollbackfor the inverse ones, and an exit-code contract mirroringnetviz plan: 0 converged, 2 changes pending, 4 refused. Seedocs/commands/converge.md. -
Namespaces are containers you can drag things into. The editor draws a frame per namespace level whenever the diagram is grouped by namespace, captioned with the namespace and how many elements are under it, with a triangle that folds it into the single node
netviz render --collapsewould draw. Dropping an element — or a multi-selection, or a whole container — inside a frame runsnetviz edit move: the document is rewritten into that directory, and every cable, tunnel, group, layout and annotation that referred to it is re-spelled. Dropping on empty canvas moves it to the root.A drop is refused before anything is written, naming both sides: a name already taken in the target namespace, two dragged documents that would collide with each other, or a folder the loader would skip. The new
POST /api/reparentandnetviz.edit.containers.move_planare the one place that decides, so the browser's drop and the command line's move cannot diverge — including which file the document lands in, which stays the placement convention's answer.Containers can also be resized, and the rectangle is stored in the
groupssection of akind: layoutdocument keyed by namespace — the first thing that writes one from the canvas. Handles are only offered on an arranged diagram and only for a namespace Graphviz boxes, because anywhere else the engine sizes the cluster on every run and a written box would be a number nothing reads.Right-clicking a frame offers the container's own rows: fold it, create an element in it, paste into it — a paste being a drop, so the copies land in that namespace. New namespace… in the canvas and container menus makes a folder by putting the first document in it; there is deliberately no operation that makes an empty one, because a folder netviz reads is one holding a document.
ffolds the container under the pointer or the one holding the focused element. Seedocs/editing.mdanddocs/inventory-layout.md. -
A clipboard, in the editor and on the command line.
Ctrl-C,Ctrl-X,Ctrl-VandCtrl-Dover the existing multi-selection, andnetviz edit copy/netviz edit duplicatefor the same thing without a browser. On this canvas they do not move shapes about, they write documents — so a copy is three decisions rather than a memcpy, and all three are made innetviz.edit.clipboardwhere the browser, the command line and a script get the same answer.The name is deduplicated in a series (
sw1→sw1-copy→sw1-copy-2, and a copy of a copy re-joins the series rather than nesting it;--suffixand--nameoverride it). The fields two elements in one inventory cannot both have are dropped — MAC addresses, fixed IP addresses, serials, BSSIDs, PDU outlets, router ids, a rack unit; the table is indocs/editing.mdand--keep-uniqueturns it off. The links follow the set: a cable with both ends in the selection is cloned and rewired to the clones, one with a single end in it is left behind and named, and copying a cable on its own is refused rather than silently landing a second cable on a port that has one. Copying a namespace copies its subtree. Everything else — the vendor, the VLAN database, the comments somebody wrote beside them — comes across verbatim, because the copy starts as the original document's own text.Pasted elements are placed in the current view's stored geometry, offset from the originals or centred on the point a right-click named, so a paste lands where it was asked for rather than being re-laid-out.
Ctrl-Calso puts a serialised fragment — JSON holding the copied documents, their namespaces and their positions — on the system clipboard, so a piece of one inventory can be pasted into another window, another inventory, or a text editor. -
An action that draws the inventory, and a reusable workflow that publishes it. A pipeline could already gate a pull request on the inventory validating; what it could not do was show anybody the network.
.github/actions/netviz-renderinstalls netviz and Graphviz — which is not a Python dependency, and whose absence is otherwise discovered at the last step of a job that has already done all the work — runsnetviz render, and reports where the diagram landed. It defaults toformat: html, the one self-contained format: layer switcher, search box and every element's detail in a single file with nothing to fetch, and so publishable as it stands.layer: l1 l2 l3becomes three views behind one switcher,argscarries anything the action does not name an input for, andgraphviz: autoskips the install for the formats netviz writes itself. A file that is not the format it was asked for fails the step rather than the site..github/workflows/netviz-pages.ymlis that render plus a deployment: an inventory repository that calls it gets a live diagram of its own network at a URL, rebuilt from the YAML on every push, instead of the export somebody drew in draw.io eighteen months ago.runs-onis an input — a single label, a JSON array of labels, or a runner group — because the network worth drawing is often the one where a GitHub-hosted runner is not allowed near the repository;python-version: ""andgraphviz: falseare there for the same self-hosted image. Only the deploy job holdspages: write, so a render that fails cannot have reached the Pages API, anddeploy: falsegives a pull request the render as a gate without replacing the published site. Seedocs/ci.md. -
Per-element styling, with a theme layer and an editor style panel. Every diagram looked identical, because appearance was entirely implicit. Now it is inventory data: an optional
styleblock (§22) on every drawable kind and on cables and tunnels, carryingfill,stroke,strokeWidth,dash,fontColor,fontSize,shape,iconandopacity. The vocabulary is closed — a hex literal or one of twenty-six named colours, and a small enum or a bounded number for everything else — because these values end up inside Graphviz attributes and mxGraph style strings, and a free-form pass-through would be an injection. A typo is answered with the nearest legal spelling ('navvy' is not a colour … did you mean 'navy'?), underNV-Z001.Themes. A
kind: themedocument maps selectors — by kind, name, namespace glob, role or label — onto style blocks.netviz render --theme NAME|PATHapplies one; two ship (blueprint,mono);[render] themeinnetviz.tomlsets the default and a[theme]table declares an inventory's own rules inline. Precedence is documented and resolved field by field: an element's ownstyle, then the theme's rules (most clauses first, a tie broken by the later declaration), then the icon set, then the built-in palette — so a theme that sets a fill does not take a shape away.Everywhere it is drawn. The Graphviz backends and the draw.io export honour all nine fields; a colour chosen in a manifest opens in draw.io as that colour.
-f jsonpublishes the resolved style beside each node and edge with afrommap naming which layer chose each value. Mermaid restates the palette asclassDefrules and has nowhere to put a per-element one, so it ignores them.In the editor. Ctrl-Shift-Y — or Change how it looks… in the context menu — opens a docked inspector showing the resolved style of the selection, which layer each value came from, and the controls to change it. A change is a batched
Setonspec.style.*over the whole selection: one entry in the changes drawer, one Ctrl-Z. Reset to theme unsets the field rather than writing the inherited value, because writing it would pin today's theme into the document and break the inheritance the button is named after.--no-stylerenders the plain diagram from the built-in palette alone — the answer to "is this odd because of the network or because of the stylesheet?" — andW144/W145catch a style that defeats itself: an element faded to nothing, a label the colour of the box behind it. Seedocs/styling.md. -
Annotations in the editor. The notes, areas and legends of §21 could be written in YAML and drawn by every backend, and were the one part of an inventory the canvas could not touch. Now: Shift-N — or New note in the canvas menu — drops a note where the pointer is and opens it for typing; right-clicking an element or a link and choosing Note about it… anchors the note to that instead, so it follows the device when the diagram is laid out again. A note is retyped by double-clicking it (Ctrl-Enter writes, Esc abandons), moved by dragging it and resized by its corner; a zone pinned to a rectangle is dragged by its outline and resized by its corners; Delete removes whichever is selected. A zone drawn round its members has no box to move, so dragging one is refused with that sentence rather than quietly turned into a rectangle.
Every gesture is one
create-annotation,set-annotationordelete-annotationbatch through the same write path a rename takes — one entry in the changes drawer, one Ctrl-Z, comments and key order intact. An annotation that has never been placed gets its wholespec.geometryblock in a single write, because anxwith noyis a position that places nothing.Alt-N, and a notes checkbox beside IPs and VLANs, hides the lot — a per-view switch that writes nothing, because commentary is never topology. The
/api/graphanswer carries anannotationspayload beside itsgeometry, in the formnetviz render -f jsonpublishes, and&annotations=0leaves it out. -
Multi-select, bulk edit and alignment in the editor. The canvas could only ever act on one focused element, which was the largest remaining gap against draw.io. It now has a real selection: drag on the paper for a rubber band, Shift- or Ctrl-click to add or take away, Ctrl-A for the whole view, Shift-arrow to extend along the links, Esc to clear.
The selection is a set of element addresses, not of shapes, which is what lets it survive a redraw and lets an element culled off screen stay in it on a thousand-device inventory. It is drawn as a halo over the drawing rather than as a class on each shape — a culled element has no shape left to mark — and it is mirrored into the accessible outline, where a screen reader hears the count and each selected entry as pressed.
With more than one thing selected, Delete asks once, listing what goes and the cables that will dangle as a result, and writes the lot as one entry in the undo stack; Set a field, Remove a field and Move to another file apply to every selected element in one batch. Behind them is
netviz.edit.Batch: N typed operations across N documents as a single transaction — all-or-nothing, one conflict check, one save, one inverse. A batch whose seventh operation is refused leaves the tree byte-identical to the one it started on. -
Align, distribute and snap-to-grid. Nine commands that mean nothing about a single shape:
align.left/centre/right/top/middle/bottom,distribute.horizontal/verticalandgeometry.snap, from the palette or by right-clicking inside a selection. Each is computed bynetviz.edit.arrangeagainst the tree'skind: layoutdocuments and emitted as oneset-geometryper document that loses an entry — so a whole alignment is one reviewable YAML diff and one Ctrl-Z, and an entry that did not move comes out byte-identical. The grid pitch is the inventory's, innetviz.toml:[editor] grid = 20 # points; the default -
A context menu in the editor. Right-clicking the diagram now offers the handful of commands that make sense where you clicked: on an element, on a link, and on the paper between them, where
New ▸lists every element kind and creating one is two clicks and a name. Until now the only way to reach a command wasCtrl-K, which you had to know about first.It is a view of the existing commands, not a second set of them. Every row runs a binding from
netviz.web.bindingsunder that binding's own id, and the same table now declares which rows each target offers — so a menu cannot offer a gesture the keyboard does not have, anddocs/commands/web.mdgenerates the menus beside the shortcuts. Every row prints its own chord, the way a palette row does.What the pointer adds is a target: right-clicking a shape focuses it first, so
Delete itdeletes the one under the cursor rather than the one the keyboard was left on, and the menu's heading is that element's address so there is no doubt which. A row that cannot run is greyed with the reason rather than hidden, Shift-F10 and the menu key open it from the keyboard, the arrow keys walk it and Esc leaves it. Right-clicking a bend still removes that bend and shows no menu. -
A published demo site: https://blechschmidt.github.io/netviz/. Until now nothing in the project let a stranger see what netviz does without first installing Python and Graphviz. The site is the whole documentation set as browsable pages, and — the part that matters — every example inventory rendered by
netviz render -f html, so the layers, the filters and the per-element detail are clickable at https://blechschmidt.github.io/netviz/demo/.Those pages are the command's own output rather than a viewer written to look like it, which is what keeps them honest: there is no second front end to fall behind the first.
.github/workflows/pages.ymlrebuilds and deploys on every push tomain, runs on pull requests without deploying, and fails if any example fails to render — a demo site quietly missing the inventory that stopped working would be a regression nobody sees. The builder istools/build_site.py, one command to run locally, and its one dependency is the newsiteextra.docs/getting-started.mdnow opens with Try it without installing, and the README carries a badge pointing at the demos. -
A first-run guided tour in the editor.
netviz web DIRopens a canvas and a command palette with four dozen entries, and nothing on screen said which of them was the point. The tour says it in about sixty seconds: it creates a device, cables it to one of yours, moves its document into another file, opens the changes drawer on the YAML all three gestures wrote, and undoes the lot — proving that every shape on the canvas is a document and that the mapping runs both ways.Every step is a real batch through
netviz.edit, because a tour that mimed its writes would demonstrate the one thing it exists to demonstrate least well. What makes that safe is where the writes land: starting the tour copies the inventory's documents into a temporary directory and points the page at a second, always-writable session over the copy, so your files are never touched — and a read-only session can take the tour too, which is the session somebody exploring is most likely to have open. The copy is deleted when the tour finishes, when it is skipped, when the tab closes and when the server stops.It is offered once, on a first visit;
Escdeclines it for good andCtrl-K→ Take the guided tour runs it again. Keyboard-driven throughout, audited by axe-core like every other dialog on that page, and covered end to end intests/test_browser.py. -
netviz exportnow writes the configuration a device would actually run. Six new formats —netplan,networkd,ifupdown,frr,wireguardandinterfaces— generateetc/netplan/10-netviz.yaml, a.network/.netdevpair per stacked link,etc/network/interfaces,etc/frr/frr.conf, a wg-quick.confper tunnel, and netviz's own vendor-neutral grammar for every device the other five have nothing to say about.Everything netviz exported until now was about the network — a hosts file, a zone, a pull list, a monitoring target. None of them is the network. The configuration a device runs is, and until this existed the inventory was a document beside the truth rather than the source of it: somebody still typed the addresses into the box, and the typing is where the two started to disagree.
Nothing is invented. A value the inventory does not state is not in the output; where a dialect requires one netviz deliberately does not hold — a WireGuard private key, a wifi passphrase — an un-runnable
REPLACE-MEis written instead, because an inventory holding key material would be a secret in version control (docs/schema.md§14.2). Three values are derived rather than read, and each is a derivation from stated facts rather than a guess: a peer's WireGuardEndpoint, from the underlay port its tunnel interface names and the address that port declares; a static route's interface, from the port whose prefix covers the next hop, whichE032already requires to be on-link; and whether avlanblock is a filter on a port or a description of a broadcast domain.A field a dialect cannot express is a refusal, not a silent omission: the whole run fails with exit code
4, every refusal names the field as the document spells it (spec.interfaces[2].vlan), and nothing is written — a configuration missing one field is a device that is almost what the inventory says, with nothing in the file to say which part. A field merely outside a dialect's remit is a manifest skip naming the dialect that does cover it, and the file is still written.--out DIRwrites the tree: one directory per device, named after its fully-qualified name, each file at the path the device keeps it at. A file netviz generated is overwritten; one it did not is refused until--force; nothing is ever deleted, and stale files from an earlier run are reported. Without--out, a single device goes to stdout, sonetviz export netplan --name pc-desk | ssh pc-desk 'cat >…'works and a wider selection is a usage error rather than a stream nobody can split.Every generated file carries
netviz-dialect,netviz-elementand onenetviz-sourceper inventory document behind it.netviz driftandnetviz importread those keys and the same six dialects back, so generate-then-compare needs neither--fromnor--hostand the round trip is exact.frrandwireguarddescribe part of a device rather than all of it and are therefore additive-only in a drift report; the other four are whole-device inputs.docs/export.mdhas the full treatment anddocs/commands/drift.mdthe capability table. -
netviz test: the inventory can now be tested the way code is. A newkind: testsuitedocument (docs/schema.md§20) holds named assertions about the network, and the command grades them and exits non-zero when one has stopped being true.netviz validateanswers "do these files cohere?" — a cable endpoint resolves, an address is inside its subnet. Every rule it applies is a statement about inventories in general, which is exactly why none of them can say that the ward switch must not be the only path to the ward. That is a fact about this network, known only to the people who built it, and until it is written down it survives only as long as the person who remembers it.Eleven assertions:
reachable/not-reachablebetween two endpoints on a named layer,path-shorter-thana hop count,same-vlan/distinct-vlan,within-prefix,has-interface,port-count-at-least,uniqueover a field expression,countcomparisons, andno-single-point-of-failure. All of them run over the graphsnetviz renderdraws and the searchnetviz pathruns, so a failing test and a drawn diagram cannot disagree about what is connected to what.Selectors reuse the filter vocabulary
netviz renderalready parses —select: kind=switch, namespace=sites/north, name=sw-*— so nobody has to learn a second query language, andfrom/totake the three spellingsnetviz pathtakes plus a selector, which turns "every access switch reaches the core" into one line.A failure names the assertion, the elements, what the graph actually contained, and the file and line the assertion is written on, taken from the loader's provenance, so an editor and a CI annotation both link straight to it.
-F jsonis the whole run for a script;-F junitis the XML GitHub, GitLab and Jenkins all render natively, one<testcase>per assertion with the source location as attributes.--listprints what would be graded without grading it.A run that checked nothing fails: an empty selection, a
SUITEglob matching no suite, and an inventory declaring no suite are all errors rather than vacuous passes. Suites ship for both bundled examples (examples/home-lab/tests.yaml,examples/campus/tests.yaml);docs/commands/test.mdis the reference anddocs/ci.mdhas the pipeline snippets. -
The editor is usable on a thousand-device inventory. Every feature so far had been built against a five-device example.
tools/bench_editor.py(new) opens the 1056-device, 2106-document treetools/bench_pipeline.pygenerates in a real browser and reports what a person actually waits through — cold open, the re-render after one field, the latency from a write to the canvas, the tab's heap and DOM, and a fifty-node move.The worst of what it found was not on anybody's list: drag one node and every redraw afterwards took 58 seconds. A drawing with some positions stored is laid out twice, and the first of those runs — which exists only to read coordinates back — was also being asked to route the edges, whose answer it discards.
neato's spline router on nodes it did not place is superlinear. The probe run no longer routes anything, the overlap repair that follows it buckets nodes into a grid instead of comparing every pair, and the same redraw is now 2.1 s for the identical drawing.Three more, each half a second of every edit: the write path reparsed the whole tree three times because the parse cache reached the read path and not the write one; the validator ran four times over objects that had not moved; and every answer carried all 2101 findings — 538 kB and that many DOM rows, for a list nobody reads past the first screen of.
In the browser, a drawing above four hundred elements is now culled to the viewport plus a margin: 140 of 2106 elements drawn and 2 872 DOM nodes instead of 12 682. Zoomed out past the point where a device name is a smudge, the labels and icons come off and each namespace grows a frame with its name on it. Nothing about reaching an element changes — the arrow keys, the outline, the palette and find-in-diagram work from the records rather than from the drawing, and selecting something off screen brings it back.
And the page says what it cannot make fast: a layout that has not come back counts the seconds rather than sitting still, and a culled canvas says how much it is drawing and how to reach the rest. The measured ceilings are in
docs/follow-ups.mdentry 20;tests/test_editor_performance.pystops any of it being given back.1056 devices Before After cold open 1565 ms 1345 ms edit one field 1736 ms 635 ms move a 50-node selection 2056 ms 950 ms redraw after dragging a node 58 152 ms 2 119 ms -
A round trip with draw.io:
netviz export drawioandnetviz import drawio. netviz's pitch is "draw.io for infrastructure, with the YAML as the source of truth". This is where it meets the actual tool: a diagram can be handed to a stakeholder who has never installed netviz, edited in draw.io, and brought back as a reviewable changeset.Export.
netviz export drawiowrites an mxGraph model of one view —--viewpicks which of the nine — carrying the stored arrangement (§18), so the file opens already arranged rather than as a heap draw.io lays out afresh. One vertex per node, one edge per link with its waypoints, a container frame per namespace so dragging a site carries its devices, and the shipped icons inlined as data URIs so the file is self-contained.--icons,--frames/--no-framesand--compress/--no-compresssay how; the plain encoding is the default because a diagram that is text is a diagram that reviews and diffs, and draw.io opens both.Identity, not labels. Each cell carries
netviz:name,netviz:kind,netviz:document,netviz:hashand the coordinates it left netviz at. That is what makes the label free to mean something on the way back.Import.
netviz import drawio FILEreconciles by those attributes. A cell that moved becomes a geometry write, one whose label was retyped becomes arenamewith every reference rewritten, one that is gone becomes a cascadingdelete, and an edge somebody drew becomes aconnecton the first free port at each end. Everything is expressed asnetviz editoperations and shown as anetviz planchangeset, confirmed before a single file moves.--dry-run,--auto-approveand a switch per gesture.What it will not do. A missing cell is a deletion only when the file says it held the whole view: export narrowed by
--namespaceand nothing is ever deleted on the strength of it. A file netviz did not export carries no identity, so nothing is reconciled — it is read and reported cell by cell, with the kind each one looks like, and netviz will not invent hardware from a rectangle. Re-importing an untouched export changes nothing at all, which the suite asserts as an empty plan over every published example and every view.netviz importis now a group, and its original signature still works unchanged: anything that is not the name of a sub-command is read as a capture file, sonetviz import caps/*.jsonmeans what it always did.docs/drawio.mdis the workflow, including what a draw.io user may and may not safely change. -
Links are first-class geometry: waypoints, routing styles and label positions. A
kind: layoutdocument already said where every node went; it now says how every cable gets there. In a hand-arranged diagram that was the last thing that did not stay where its author left it — an edge was whatever Graphviz decided, on every render.Waypoints.
spec.views.<view>.edges.<address>.waypointsis the list of bends a link is routed through. They are interior points: the two ends of a route are the nodes themselves, so dragging a device carries its cables along instead of stranding them. In the editor, click a link to select it, double-click the line to drop a bend where you clicked, drag a bend to move it, drag the hollow midpoint handle to insert and place one in a single motion, and right-click a bend to remove it. From the keyboard,badds a bend,Shift-Bstraightens the link andrsets its routing style. Every one of them is aset-link-geometryoperation through the same comment-preserving write path as any other edit, so a bend dropped in a browser is a hunk in a YAML file.Routing styles.
spline(the curve Graphviz draws, unchanged and still the default),orthogonal(right angles) andstraight(segment to segment) — settable per link, per view (views.<view>.routing) and per inventory (spec.routing), most specific winning.--routingonrender,watch,diffandpath, androutingin the[render]table ofnetviz.toml, set a default that a link pinning its own style still beats;netviz layout --write --routing STYLErecords the view's. For a fully arranged view netviz computes each route itself and writes it into the Graphvizpos, which is the only way a per-link style can be expressed at all — Graphviz has a graph-widesplinesand nothing per edge. For a view Graphviz is laying out, only that graph-wide attribute is available, and netviz now says out loud what it could not honour rather than emitting a document Graphviz quietly draws differently.Label positions.
edges.<address>.labelis{at, offset}— how far along the route the annotation sits, and how far off the line. Stored on the link rather than as a coordinate, so it survives both endpoints being dragged, which is the whole reason a label gets nudged. Applied by the DOT, SVG, PNG, PDF and HTML renderers, and published by JSON.Parallel links and self-links. Two cables between one pair of devices no longer land on the same line in a fixed drawing: they are fanned 14 points apart, centred so a lone link is not moved, and each gets a grab handle of its own. A bundle folded by
--bundle-linkscounts once. A self-link is drawn as a ring standing off its node, so four VLANs terminating on one switch are four rings rather than one thick one.netviz render -f jsonpublishes both halves per edge: what the inventory pinned (waypoints,routing,label) and, for an arranged view, the line that was drawn (route,controls,drawnAs). Seedocs/rendering.md, which carries a worked example checked against a committed golden. -
A history timeline:
netviz log, two revisions onnetviz diff, and a scrubber under the canvas. The inventory is a folder of YAML in a repository, which means its whole history is renderable — and nothing else in this space can show you when a network became what it is.netviz loglists the commits that touched the inventory, newest first, with a one-line summary of the changeset each one carries:3 devices added, 1 link removed, 2 addresses moved. The summary is a real changeset, computed by the same codenetviz planandnetviz diffuse, so a commit that only reformatted a file saysno change to the network.--from/--totake a range,-na count,--jsona document per commit, and--no-summarythe commit list alone with nothing read.netviz diff --from <rev> --to <rev>now reads any two revisions — a tag against a tag,HEAD~10againstHEAD— with--tostill defaulting to the working tree. Both sides come out of the object database withgit archive: nothing is checked out, the index is untouched, and an uncommitted change is neither used nor disturbed.In the editor,
History(Ctrl-Shift-H) opens a scrubber along the bottom of the canvas over those commits. The diagram repaints as the diff overlay for the selected commit against its parent, with the subject, author, date and summary beside the control;Alt-Left/Alt-Rightstep,Alt-Pplays through the range. Positions come from the layout document as that revision had it, so a diagram that was arranged stays arranged as you scrub.It is honest about its edges. A revision whose inventory does not load is shown as such — and stops the playback — rather than being skipped; a revision from before the inventory folder existed reads as an empty network rather than as a failure, and says so; a range wider than
[history] max-revisions(100 by default) is refused bynetviz logand truncated-with-a-count by the editor rather than becoming two hundred Graphviz runs. Frames are cached by the pair of tree hashes they sit between, so scrubbing back over ground already covered is instant, and neighbouring revisions share their loaded state and their parsed files.tools/bench_history.pymeasures all of it against the 1056-device benchmark tree.docs/commands/log.md,docs/commands/diff.mdanddocs/commands/web.mddocument it. -
netviz lsp, a language server, so the editor knows what netviz knows. An inventory is written by hand in a plain text editor, and until now the editor could be told the shape of one document — through the published JSON Schema — but nothing about the tree it belongs to. The server closes that: LSP 3.17 over stdio, no new dependency, started by your editor rather than by you.Diagnostics are
netviz validate's, on the line and column that caused them, carrying theNV-*rule id as the diagnostic code and a link to that rule's section ofdocs/validation-rules.md. Completion is the JSON Schema for keys, enums and their documentation, and the tree for references: typing under a cable'sendpointsoffers the switches you have, andsw-home:offers the ports that switch has. Hover resolves a reference to the device, the port, its addresses, its VLAN and what is already cabled to it. Go-to-definition and find-references work across the whole folder. Rename goes through the same write path asnetviz edit rename, so every reference in every file is rewritten with the comments intact. Formatting isnetviz fmt; the code actions are the--fixcatalogue.It answers about the text on your screen — unsaved buffers are overlaid on the tree before it is loaded — and it watches the folder the way
netviz watchdoes, so an edit made in a terminal refreshes the diagnostics. Opened on a lone file rather than a folder, the checks that can only be judged against a whole tree are held back rather than reported against a document that cannot satisfy them.docs/lsp.mdhas the setup for VS Code, Neovim, Helix and Emacs, and a minimal VS Code client ships ineditors/vscode/. -
netviz validate --fixrepairs what the inventory itself determines, and the editor puts afixbutton on each of those diagnostics. Half the value of a diagnostic is knowing what to do about it, and for a good part of the catalogue the tree already says: akind: layoutdocument placing an element that has been deleted, a MAC address on a software loopback, a port trunking a VLAN its device's database does not declare, a VRF nothing is bound to, a group still listing somebody who has left, a cable endpoint naming a port one letter away from one that exists.--fixapplies every repair that has exactly one reading and reports the rest;--fix --dry-runprints the unified diff and writes nothing;--choose W114=listdecides a rule that has two. Writes go through the same path asnetviz edit, so comments, key order and quoting survive and only the lines the repair is about change.A fix never introduces a finding. Each is applied on its own and the tree is validated again; unless the finding it was aimed at is gone and no rule reports more than it did before, the bytes are put back and the refusal is printed with the findings it would have added. So "remove the cable" is offered, and refused on a two-device inventory where it would orphan a device — which is a decision for a person.
netviz rules --fixablelists what can be repaired and what each repair does;docs/validation-rules.mdsays the same, generated from the table so it cannot drift. -
A fourteenth edit operation,
append, which adds one entry to a sequence and creates the sequence if it is absent.setcannot add a list entry that does not exist yet, and replacing a whole list to add one would rewrite the comments beside the entries already in it. Its inverse is anunsetof the position it wrote. -
The editor can be driven entirely from the keyboard, and read without a screen.
netviz webwas becoming pointer-only, which is where visual tools stop being usable for the people who work fastest in them.Ctrl-Kopens a command palette over every command the page has — everynetviz editoperation, every view and layer toggle, open-file, go-to-element, validate, the changes drawer — searched in one field alongside every element address and file path in the inventory. Each row prints the key that runs it, so the palette teaches the bindings; a command that cannot run now is greyed with the reason rather than hidden.?opens the shortcut sheet.The diagram is navigable.
Tabreaches the canvas, the arrow keys walk it — preferring the elements the focused one is linked to, so a path is followed rather than a grid swept —Enteropens the inspector, andn,c,F2andDeleteare the create, connect, rename and delete gestures. The focus ring is deliberately not the selection ring: solid violet against a long dash, with another client's selection a short one.The SVG is no longer inert. Every node and link carries a role and a label built from the same record the info box uses — "sw-home, switch, 8 interfaces, linked to routers/rtr-home on port1" — the canvas announces which element is current,
Alt-4opens the whole view as a textual outline, and every applied, refused or reverted gesture is announced in a live region, once.The interface now follows
prefers-color-schemewith a palette per scheme (one set of colours cannot clear 4.5:1 against both a white and a near-black background) and honoursprefers-reduced-motion. The diff legend prints+,~and−and three line styles beside its three hues, so the encoding survives a greyscale print and a red-green reader.It is gated:
tests/test_browser.pyruns axe-core over the page in both colour schemes and fails CI on any WCAG 2.1 AA violation, and drives one end-to-end test — create a device, cable it, undo both — without dispatching a mouse event. The bindings live innetviz.web.bindings, are served atGET /api/bindings, and are whatdocs/commands/web.mddocuments, generated; a shortcut that is documented and dead fails the suite. -
The editor pushes instead of polling, and a second tab is a feature rather than a race.
netviz web DIRused to check a revision number once a second and, whenever it moved, refetch the whole file list and re-lay-out the whole diagram. It now opens a server-sent-events stream,GET /api/events, that says what moved the moment it does:tree-changed,file-changed,history-changed,disk-changed,presence.Two consequences, both measurable. A save of one file refetches that file's row (
GET /api/tree?path=…), not the tree. And a revision that does not change the picture on screen does not redraw it: the page sends the fingerprint of the drawing it is showing, and the server compares it with the DOT this revision would produce and answersunchangedrather than running Graphviz. On a 1056-device tree, editing a description went from 1.7 s to 185 ms;tools/bench_events.pyis the harness anddocs/follow-ups.mdentry 18 has the table.It falls back. A buffering proxy, a browser without
EventSource, a stream that will not open — any of them drops the page back to polling/api/state, which replays the same events with the same ids out of the same ring buffer into the same handlers. A client that makes plainGETs,curlincluded, never has to know the stream exists, and no write is gated on having read one. An indicator above the file list says which path you are on. -
Presence and soft locking in the editor. Every connected page is listed, what somebody else has selected is drawn on the canvas as a faint dashed halo, and a file another client has unsaved edits in is badged
in use.Advisory throughout: it blocks nothing, it expires by itself if a tab goes away without saying so, and the only things that can refuse a write remain the content hash of a whole-file save and the tree revision of an operation batch. A lock a heartbeat can hold is a way to lock an inventory by closing a laptop lid. Two new routes carry it:
POST /api/presenceand theclientslist onGET /api/state. -
netviz diff, and a changes drawer in the editor: a changeset, drawn.netviz planalready answered what changed and every renderer already answered what the network looks like; nothing put the two together.netviz diffrenders one diagram holding both states — added elements and links green, removed ones red and dashed but still in place, changed ones amber with a badge naming the fields that moved, everything untouched faded.A removed node keeps the position its layout document gave it. A deletion that reshuffled the diagram would hide itself in the churn it caused, which is the one thing a change review cannot afford.
Two things decide the marks and there is no third opinion about what changed. Presence in the two drawings decides added and removed — the only thing that can answer for a derived node, since nothing declares
subnet:10.0.0.0/24. The plan decides everything finer: that an element was updated rather than merely still present, which of its fields moved, and that a box is the same device under another name. A rename is therefore one amber box badgedwas <old address>, not a red box beside a green one.The two sides come from wherever
netviz planreads them, plus--against HEAD(the same side as--from, spelled the way the question is asked) and--plan FILE, which executes a saved plan into an edit session that is never committed — so what is drawn on the right is the textnetviz applywould write, not a reconstruction of it. Everyrenderformat is supported except Mermaid, which can neither colour a node nor hold a changeset beside one and says so rather than drawing a diagram in which nothing distinguishes the deleted switch.-f jsonpublishes adiffobject on every node and edge — untouched ones included — plus the whole changeset underchangeset. -
A changes drawer in
netviz web --write. It lists every gesture made in the session — one entry per gesture, not per operation, so deleting a switch is one line rather than five — each with the YAML hunk it wrote as a unified diff, a click on its label that reveals the document it changed at its line, and a per-entry Revert.A revert is a new change, not a rewind: it applies the gesture's own inverse as a fresh edit, which is itself logged and itself undoable, so reverting the third of ten gestures leaves the other nine alone — and fails, loudly and without writing, when one of them depended on what the third one did.
Opening the drawer repaints the canvas as a diff against the state the session started from, or against
git HEADwhen the inventory is in a repository, so an afternoon's editing can be reviewed as a diagram before it is committed. Three new API routes carry it:GET /api/changes,GET /api/diff?against=session|gitandPOST /api/revert. -
A handover button. Copy commands hands the session over as a list of
netviz editinvocations, in the order they happened, for a pull-request description or somebody else's terminal. The rendering is never lossy: an operation a subcommand takes exactly becomes that subcommand, and one it does not becomesnetviz edit apply -f -with the operation's own JSON on standard input. There is deliberately no third case where a rendering approximates an operation. -
userandgroup, two new element kinds: who the network is for. Every other kind in the schema answers what is there. These answer whose is it, and who may touch it — the question an audit asks first and the one an inventory of boxes and cables cannot answer at all. Both are ordinary elements: they load, validate, format, diff, apply, render and export through exactly the machinery every other kind goes through, and neither owns interfaces, so an identity terminates no cable and appears in no data layer.A
usercarries the account —login(defaulting tometadata.name),full_name,email,uid, atypeofperson/service/shared, astatusofactive/suspended/departed, andssh_keys. Public keys only: a pasted private key is refused with an explanation, which is the mistake the check exists for.A
groupcarriesmembers,gidandemail. A member may name auseror anothergroup, so a hierarchy is expressible:everyoneholdsengineeringholdsana. Membership is written on the group and nowhere else — auserdoes not list its groups, because two spellings of one fact are how an inventory starts disagreeing with itself — and the reverse index is derived where it is needed.Ten new rules, lettered
Sfor subject:NV-S001–NV-S003on the documents, andE043–E046,W139,W140andI004on the tree. The one worth knowing about isW140: a group that still lists somebody whose account isdeparted. Deleting a leaver's document removes them from the inventory and from every group naming them, losing exactly the list of access somebody has to go and revoke.status: departedkeeps that worklist visible until it has been worked through. -
netviz render --layer identity, the ninth layer: the users and groups, joined by membership, and no hardware whatsoever. A user is drawn as an oval and a group as a folder, in a rose palette no element kind had taken, and both have an icon in the bundled theme. A membership edge runs from the group to the member — the direction the fact is written in. Everything else is discarded, for the same reason the power view discards the cabling: a cable between two servers says nothing about who may log into either. -
netviz list usersandnetviz list groups.usersprints aGROUPScolumn, which is the one fact about a person their own document cannot state.groupsprints two member counts:MEMBERSis what the document names,PEOPLEis how many accounts the group reaches once the nesting has been walked — the number an access rule actually grants to, and the number no single document holds. Both tables also appear on the Identity section ofnetviz report. -
netviz planandnetviz apply: a typed changeset between two inventory states. The inventory is meant to be a source of truth that can be diffed and applied, and until now only the read half existed:netviz driftcompared a live network against the declaration and reported. The general diff engine and the write half are now here.Every element has a stable address —
device.core/sw-1,cable.core/uplink— whose type is a category rather than the document'skind, sokind: switch→kind: routeris an update of one element rather than the destruction of one and the creation of another.netviz plandiffs two loaded inventories into an ordered changeset ofcreate,update,deleteandrenameentries, each with the address and the field-level before/after pairs, and prints it in the terraform shape (+ 3 to add, ~ 5 to change, - 1 to destroy).Three things make the output worth reading. Renames are detected structurally — by serial, MAC, link ends, cable label, rack slot or a
netviz.dev/idannotation — so a renamed switch is one entry rather than a delete plus a create, and only where the evidence names exactly one element on each side. The entries are in dependency order: a cable is destroyed before the device it terminates on and created after it. And the comparison is of meaning, not text — templates merged, ranges expanded, defaults filled in — so a tree somebody has just runnetviz fmtover produces an empty plan.The two sides come from wherever they can:
--from <git-ref>against the working tree (read withgit archive, so the working tree is never disturbed), two folders with--from/--to, or--from-livereusing theimportanddriftcollectors. The last is the one that closes the loop: the desired state is the declaration with the observations written into it, never the capture rendered as YAML, so a partial capture proposes corrections and never a cull — a declared cable is only removed where a port contradicts it, a re-patched lead keeps its document, and a trunk's VLAN set is merged rather than substituted.netviz applyexecutes a plan against the files, translating each entry into thenetviz editoperations from the previous release, so comments, key order and formatting survive and the same validation gate applies.netviz plan -out drift.plan && netviz apply drift.planadopts what the network reports into the inventory. The plan file records a hash of the state it was made from and apply refuses a tree that has moved on;--targetapplies a subset,--auto-approveskips the confirmation,-nprints the diff instead of writing it.--jsonand--fail-on changesare for CI.Applying to the live network is deliberately out of scope.
netviz applywrites YAML and nothing else: it opens no session to a device and there is no flag that makes it. Seedocs/commands/plan.mdanddocs/commands/apply.md. -
netviz layout, and a diagram that stays where you put it. Until now the picture was derived: Graphviz laid the graph out afresh on every render, so a diagram could not be arranged — move a node and the next render moved it back. Geometry is now first-class, optional inventory data. Akind: layoutdocument holds a position per node, keyed by element address and scoped by view (l1,l3,routing, …), because the same switch sits somewhere different in each. It is a sidecar rather than a field on each device, so model files stay free of pixels and an arrangement can be dropped, regenerated or versioned on its own — the reasoning is recorded indocs/follow-ups.md§16.netviz layout --writeruns the automatic layout once and persists the result, which is what makes the diagram editable from then on; running it again places what is not yet placed rather than discarding an afternoon of arranging (--replaceasks for the whole view afresh,--enginepicks a different Graphviz engine).--cleargoes back to automatic,--prunedrops geometry for elements that no longer exist. With no flags it reports what is arranged and what has gone stale. Writes go through thenetviz editpath, so comments and formatting survive and--dry-runshows the exact hunk.The renderers honour it with no flag of their own. When every node in a view is placed, Graphviz runs in no-op layout mode and the output reproduces the arrangement point for point — verified by a test that renders a seeded diagram and reads the coordinates back out. When only some are, those are pinned, the engine places the rest around them, and anything still overlapping is separated without moving a node somebody placed by hand.
svg,png,pdfandhtmlare the same Graphviz run andjsonpublishes the same coordinates in the same units, so a browser can draw the graph itself. Namespace frames in a fixed layout are drawn from the stored group boxes, since the no-op engine draws no clusters; their captions sit above the frame rather than inside it, for the reason indocs/follow-ups.md§17. Seedocs/commands/layout.md,docs/schema.md§18 anddocs/rendering.md. -
W138/NV-Y001, stale diagram geometry. A warning — never an error, because deleting a switch must not makenetviz validatefail — naming each layout key that no longer resolves.netviz layout --pruneis the fix. -
netviz edit, the write path. The first way to change an inventory that is as careful as the way netviz reads one. Eleven typed operations — create, delete, rename, move, set, unset, add-interface, remove-interface, connect, disconnect, and any of them as JSON on stdin — each applied through a round-trip parser, so comments, blank lines, key order and quoting style survive byte for byte and a diff of an edit is the edit. Each one is reversible exactly: it returns the operations that undo it, so an undo stack is a list and undo restores the tree comment for comment. Each one is reference-aware: a rename rewrites every mention of the element across the whole tree, keeping the spelling each document chose, and a delete either takes the cables and tunnels that terminate on the element with it (--cascade) or refuses and names them. New documents are placed by the conventions indocs/inventory-layout.md, and the last document leaving a file takes the file — and the folder — with it. Two gates stand between an edit and the disk: the tree is loaded and validated as it would be and the write is refused if it would introduce a new error (--forceoverrides), and every file is hashed when it is read and checked again before it is written, so a file that changed underneath is a reported conflict rather than a lost edit.--dry-runprints the unified diff it would write,--jsonprints the applied operations and their inverses. Seedocs/commands/edit.mdanddocs/editing.md. -
A container image published on every push.
ghcr.io/blechschmidt/netviznow also carries unreleased work, tagged after the ref it was built from:<branch>for every branch pushed (slashes become dashes, sofeature/vlansisfeature-vlans),sha-<commit>for the exact commit, andedgefor the tip of the default branch. So a fix that has landed — or a colleague's branch — can be run without a Python environment and without waiting for the release that carries it. Same two platforms, same provenance attestation and SBOM as a release, because it is now the same workflow: av*.*.*tag builds through the same file and takes the semantic version tagsX.Y.ZandX.Yfrom the tag itself.latestis unchanged and still follows releases only — a branch build has no way to reach it — so an unqualifieddocker pull ghcr.io/blechschmidt/netvizcannot land on unreleased work. The image is rebuilt weekly against a freshpython:3.12-slimand Graphviz, and every pull request now builds it for both architectures and runs it before anything can be merged. Seedocs/docker.md. -
netviz report, the as-built documentation. One command writes the document an engineer is asked to hand over: an overview, a page per site and a page per device, with the layer diagrams, the address plan and its utilisation, a VLAN-to-subnet-to-device matrix, the cable schedule with the patch panels named, the port map of every panel, the BSS and SSID plan, the PDU load schedule, each device's interfaces, placement, links and routing, and the open validation findings — so a report never presents an invalid inventory as authoritative.--format markdown(the default) is committed next to the inventory and reviewed as a diff;--format htmlis one self-contained site where every device in every diagram links to its own page;--format jsonis the whole document in one file. Every table comes from the same derivation the matching command prints, so no two pages can disagree. The output is byte-identical between runs,--generated-atpins the one part that is not, and every page carries the netviz version and the inventory's git revision.--template DIRoverrides the page templates one file at a time. Seedocs/commands/report.mdanddocs/example-report/, which is one, committed. -
Power as a modelled layer. A
pduelement kind with numbered outlets, aspec.powerblock on every device (draw, redundant inputs naming<pdu>:<outlet>, PoE budget and per-port PoE), apowerlayer that draws which strip feeds what,netviz list powerfor the load schedule,netviz export power, and seven rules (E037–E042,W137) covering a claimed redundancy that is not one, an over-subscribed strip and a PoE budget that does not add up. -
A parse cache, on by default. A file that has been parsed once is remembered, keyed by the hash of its bytes together with the netviz, parser and model versions that read them, so it cannot go stale. A repeated load costs 0.30 of a cold one in a new process and 0.05 in a running
netviz watch, where a re-render now re-parses only the file that was saved. Nothing about a timestamp enters the key: atouchchanges nothing and agit checkoutof a revision seen before hits again. -
netviz cache inforeports where the cache is, what is in it, and the identity an entry is keyed by;netviz cache clearempties it,--allfor every inventory. -
--no-cache, a global flag, parses everything and remembers nothing.NETVIZ_NO_CACHE=1does the same for a whole environment, andNETVIZ_CACHE_DIRmoves the cache — both of which is what a CI job wants. Seedocs/configuration.md. -
[cache]innetviz.toml—enabled,dirandmax-size, for an inventory that needs to say where its cache goes on the machines it is used on. -
examples/docker/, the seventh example inventory: §23 and §24 at the scale a container runtime produces them, whereexamples/containers/is the same idea small enough to hold in your head. Three Docker hosts on one switch, sixteen network namespaces, thirteen veth pairs and one swarm overlay.srv-dock-01holds the networksdocker network lsprints — the default bridge, a user-defined bridge, an--internalone, the overlay's sandbox namespace, and Docker-in-Docker with a container under it — withiptables -Swritten as zones: the isolation chains, the per-network masquerade, the hairpin rule,-p 443:8443as the two rules it really is, and a marked network routed out of a second uplink.srv-dock-02is the other end of the overlay.srv-dock-03is the three shapes that break the pattern: a pod of two containers sharing one stack, a macvlan and an ipvlan network that enter a namespace with no veth pair and no NAT, and two rootless daemons — one of them nested three levels deep, through a container to a build sandbox — whose networks a host firewall cannot see or name. Itstests.yamlwrites the design's claims as thirteennetviz testassertions. It validates clean, like the other six, and is drawn with--layer netns,--layer securityand--layer overlay. Follow-up 26 records the one thing it could not write down: there is no interface type that says "macvlan slave" or "tap", so an interface that can never terminate a cable is indistinguishable from a spare port unless it is a veth end.
Changed
-
netviz validateis about 3.1× faster on a 10 000-element inventory, and loading is about 1.4× faster; both were driven by a committed profiler rather than by guesswork (tools/profile_validate.py,tools/bench_pipeline.py). -
The
htmloutput no longer grows with the number of layers in it: the views share one document instead of each carrying a copy. -
The documentation was reorganised into a lean
README.mdand a navigabledocs/set with one page per command; every flag table is generated from the CLI and every shell transcript is either executed by the test suite or marked with the reason it cannot be. -
The project is now called netviz. Every name the old one appeared in moved with it, and all of them are breaking:
- the distribution and the command are
netviz, and the importable package isnetviz; apiVersion:isnetviz.dev/v1alpha1— an inventory written against the old group is refused by the loader rather than silently accepted, because a document that names an API group netviz does not implement is a document it cannot promise anything about;- the per-inventory settings file is
netviz.tomland the ignore file is.netvizignore; - the environment variables are
NETVIZ_*; - the published JSON Schema is
schema/netviz.schema.json, and the$idthat editors fetch moved with it; - the pre-commit hook ids are
netviz-validate,netviz-testandnetviz-fmt; - the composite actions are
.github/actions/netviz-{render,review,validate}and the reusable workflows are.github/workflows/netviz-{pages,review}.yml; - the schema ids of the validation rules are
NV-*—NV-C005, notNG-C005— wherever a rule is named:netviz.toml, anetviz/ignoreannotation,--disable, and the tables indocs/schema.md§10. The short ids (E002,W103,I001) are unchanged, because the letter in them is a severity and not a project.
There is no compatibility shim for any of them. netviz has not been published, so nothing outside this repository can be reading the old names, and a shim would be a second spelling to keep working for the lifetime of the tool in exchange for a migration nobody has to do. Renaming an existing tree is
sed -i 's/netgraph/netviz/g; s/\bNG-/NV-/g'over its YAML plus a rename ofnetgraph.toml.The GitHub repository was renamed too, which moved three URLs that name a location rather than the project: the repository is https://github.com/blechschmidt/netviz, the demo site is https://blechschmidt.github.io/netviz/ — the old path 404s, it does not redirect — and the image is
ghcr.io/blechschmidt/netviz, whichcontainer.ymlderives fromgithub.repositoryand so followed the slug on its own. - the distribution and the command are
Fixed
-
Six loader and renderer defects found by property-based and fuzz testing, all of them cases where a hand-written but unusual document was mis-parsed or crashed rather than being reported: see
tests/test_properties.pyandtests/test_fuzz_loader.pyfor the regression examples. -
Mermaid front matter escaped
"but not\, so a title containing a backslash produced a diagram Mermaid would not parse. -
The editor's style inspector emptied itself, and would not fill up again. Selecting a device and opening the panel showed nine rows once — and then, after the first view switch or the first time the changes drawer was opened, showed select an element or a link to see how it is drawn with an element plainly selected. Two causes, both of them the same mistake made twice: treating "this answer carries no resolved styles" as "this drawing has no resolved styles".
A render whose fingerprint the page already holds comes back
unchanged— no SVG, and nostyleseither, for the same reason. The page cached the SVG, the details, the geometry and the namespace boxes across such an answer and threw the style map away, so every address in the selection resolved to nothing and the panel drew its empty state. It is now cached with the drawing it belongs to, like the other four. And/api/diff— the route the changes drawer and the history scrubber render through — published nostylesat all, so the panel went blank for as long as either was open; it now resolves them off the union graph, which is also what makes a removed element still report the appearance it is drawn with.Two smaller things fall out of it. A diff on screen is called out in the panel, because the colours a changeset paints are not the values the rows show and a panel that let those be confused would be worse than one that said nothing. And an element that is selected but not in the drawing now says so —
hosts/laptop(not drawn here) — rather than nothing selected, which is the sentence that made a bug look like an empty panel rather than a broken one. Regression tests cover both routes in the browser, and the invariant underneath — everything a drawing publishes as selectable has a resolved style, including a folded namespace's aggregate node — is asserted per view. -
The documented way to pull the container did not work.
README.mdanddocs/docker.mdboth opened withdocker run … ghcr.io/blechschmidt/netviz:latest, and that tag has never existed:latestis set only bypypi.yaml, only for a non-pre-release, and no version has been released — so the first command a reader typed came backmanifest unknown. Every pull instruction now namesmain, the tip of the default branch, whichcontainer.ymlpublishes on every push and which resolves today, multi-architecture, with provenance and an SBOM. The examples that pinned a version (:0.1.0) had the same problem and now pin a commit (sha-…), which is the immutable tag that exists before there is a release to pin. The docs still describelatest,X.Y.ZandX.Yas what a release adds — a table row and prose, not an instruction — andtests/test_docker.pynow enforces that split: an image reference inside a fenced code block in either page must use a tag some workflow actually publishes today. -
netviz edit renamelost the arrangement of the element it renamed. A rename rewrote every reference to the element — a cable end, a tunnel'sover, an adapter'sattached_to— and nothing else. But two more places write a name down, and both are mapping keys rather than values: the §18 layout entries that position it, and the §21 note anchors and area member lists about it. So a rename handed back a tree carrying aW138and possibly aW142, and the coordinates were silently orphaned — the element was redrawn wherever the engine put it, andnetviz layout --prunethen dropped the position rather than moving it. All three move together now, in every view of every layout document, derived#upstreamandtunnel:keys included, and each is written in the spelling its document was already using: a short key stays short while a short key still resolves, a qualified one stays qualified, and only a spelling that would now resolve to something else — or to nothing — is promoted.netviz edit movegets the same treatment, which is where the promotion actually earns its keep. An area'sselectoris deliberately left alone: it names a pattern rather than an element, and rewriting one would be guessing. -
The address rules now know that a namespace partitions the address space. Without this a perfectly ordinary container host is reported once per container:
E004on two containers built from one image,W111on both ends of every routed veth pair,W105on the bridge every container hangs off.E004now scopes a duplicate to the stack — and to the machine, because unlike a VRF name a namespace name means nothing outside the host that runs it;W111groups by namespace as well as by VRF; andW105counts stacks rather than machines, so three containers on one bridge are three parties and not one lonely element. -
Deleting one of a server's two PDUs took the server with it. Clearing a power input is right; leaving
redundant: truebehind on the one feed that is left is not, because that flag claims the device survives losing a feed and one feed does not.E042/NV-E015is a load error, so the server stopped loading altogether and every cable that ended on it started reporting a dangling endpoint. The flag now goes with the feed it was about. -
netviz import drawioput back the coordinates of the node it had just deleted. The geometry write is built from the arrangement the tree held before the import, which still places everything the import removes, and it runs last — so a diagram with one node deleted and one dragged came back with a staleW138per node removed. The deleted keys are taken out of it, using the same closure the delete itself runs. -
The editor said "saved" and "unsaved changes" at the same time. Typing into a file and then putting the text back the way it was left the badge up, the Save button enabled and the file listed as
unsavedin everybody else's browser — for ever, because nothing short of opening another file cleared it. The page read its new state out of the list of files the write had touched, and a write whose bytes were already there touches none. The badge is about the gap between the pane and the file, not about whether the filesystem moved, and it now says so.Three more of the same shape, found by the tests written to reproduce that one:
- A change made on the diagram silently threw away unsaved typing in the pane. A
canvas gesture is applied to the file, so a page holding unsaved text in that file has
two documents in hand — and it used to adopt the file and drop the text without asking.
The change still goes to the file; the pane is now marked
conflictand keeps your text, which is exactly what it already did when$EDITORwas the one that moved underneath. - Deleting an element left its file on screen, badged
deleted on disk. The editor blaming the filesystem for something it had just done itself, over a document one Ctrl-S would have put straight back. The pane now closes — unless it holds unsaved text, in which case that text is the only copy left and it stays, badged for what it is. - "save again to overwrite it" could not be taken up. A file changed on disk under
unsaved edits is a
conflict, and the toast that says so offers the way out. Saving again stopped quoting a precondition — meaning "over whatever is there" — but an absent hash is how the write route spells create, so the second Ctrl-S came backalready exists; open it before writing to itand the offer was a lie. The page now adopts the hash the refusal reported and quotes that, so the retry writes over the version it was told about and a file that moves a third time in between is refused again.
Also: a
file-changedevent that overtook the response to the write that caused it could badge a plain Ctrl-S as somebody else's conflict. The page now ignores the echo of its own writes, as it already did fortree-changed. - A change made on the diagram silently threw away unsaved typing in the pane. A
canvas gesture is applied to the file, so a page holding unsaved text in that file has
two documents in hand — and it used to adopt the file and drop the text without asking.
The change still goes to the file; the pane is now marked
-
Nothing could be drawn on Python 3.11: importing
netviz.renderraisedValueError. Two fields of the style resolver defaulted to amappingproxy, whichdataclassesrefuses before 3.12 and refuses at import time — so on the 3.11 the package claims to support,netviz render,watch,web,exportand everything else that reaches the renderer failed on the import, not on the work. This is the second time the same shape of bug has shipped (netviz lsp, above), so the guard is now the package rather than the module:tests/test_release.py::test_no_dataclass_default_is_a_mapping_proxyimports every netviz module and inspects every dataclass field, on whatever interpreter you run it with. -
netviz plannamed a folder with backslashes on Windows. The header of a plan, and thedescriptionrecorded in a plan file, spelled--to examples/home-labback asexamples\home-lab— so the same command against the same two trees produced a different document depending on who ran it. Every path netviz prints for a person to read now goes through one function,netviz.fsio.display_path, which is where the "relative to here, forward slashes" rule thatnetviz fmt,driftandtestalready followed now lives. -
Orthogonal links were drawn straight across the devices they passed. An arranged diagram with
routing: orthogonalrouted each leg locally and avoided nothing, so a cable between two switches with a third between them was drawn across the third one's box — the most visible thing wrong with a hand-arranged picture, recorded asdocs/follow-ups.md§19 and left there for four releases because nobody had a number for it. There is one now:tools/route_crossings.pycounts the links whose line runs across a box they are not attached to, and on the committed fixtures it goes from five and three to zero.Routes now go around. Every placed node — and every free-standing
areaand placednote— becomes an obstacle, and an A* over their Hanan grid finds the cheapest way past, charging for bends, for crossing a line already drawn and for crowding a channel. Several links between the same two devices route as one bundle in parallel lanes rather than as separate detours that fan out and re-converge.Nothing you placed is moved, and nothing is written to your files. A bend you dragged is authoritative — routing fills the segments between bends and never touches one — and a link whose line already keeps clear renders byte-identically to before. A computed route is recomputed every render;
-f jsonpublishes it aslayout.routedbeside the bends the inventory pins, and innetviz webShift-R(Pin the computed route) is what makes one permanent.--no-avoid, oravoid = falsein[render], turns it off. Seedocs/rendering.md. -
netviz could not be imported on Python 3.11, and the timeline crashed on 3.10. Two separate instances of the same shape of problem, both found by the CI matrix and neither reachable from the version the work was done on.
netviz lspdeclared amappingproxyas a plain dataclass default, which is refused before 3.12 and refused at import time, so every LSP command raisedValueError: mutable defaulton 3.11. Andnetviz historyparsed git's%aIwithdatetime.fromisoformat, which before 3.11 accepts+00:00and not theZsome builds of git write, so a timeline over such a repository raisedValueError: Invalid isoformat stringon 3.10. Both are one line; both now have a test. -
Clicking a node in
netviz webdid not open the document that declares it. The page passed the SVG element id where the tree is keyed by element address, so the lookup matched nothing and the click silently did nothing at all — the one mapping the command exists for. -
Ctrl-Zinnetviz webleft the editor showing text that was nowhere on disk. The undo restored the file correctly and the pane kept the version it had just replaced, under a badge that said there was nothing unsaved. The pane is now reloaded from the file the undo produced. -
A change made outside
netviz webwas noticed and then ignored. The open file was compared against the previous file list rather than the one the change had just been fetched into, so the hashes always matched and the one file that had moved on disk was the one thing left stale. Editing an inventory in$EDITORwith the browser open now reloads it there, or marks it conflicted when the pane has unsaved edits. -
A refused request left the connection unusable in both local servers. Every refusal that answers without reading the request body — a 404 for an unknown route, a 403 from a read-only session, the host check's 421 — stranded that body in the socket, where HTTP/1.1 keep-alive made the next request on the same connection parse out of it. The symptom was a
501 Unsupported methodnaming a fragment of JSON, on a request that was perfectly well formed. -
netviz could not start on Python 3.11. Every command raised
ValueError: mutable default <class 'mappingproxy'>while importing the configuration layer. 3.10 and 3.12 were unaffected, which is why it survived a full test matrix — the interpreter in the middle was the only one that refuses that spelling of a dataclass default. -
A lone surrogate escape now fails to load under either YAML parser.
description: "\ud800"names a code point UTF-8 cannot encode, so every artefact netviz writes would have raised on it. libyaml refused it and the pure-Python parser accepted it, meaning whether an inventory loaded depended on which PyYAML wheel was installed. -
The nesting-depth guard is now a limit both parsers survive. It was 1024, which is past the pure-Python composer's own ceiling, so a document exactly at the documented limit was refused there and accepted with libyaml. It is 256 — still four hundred times deeper than the schema goes, and the same answer on both.
-
W129reported its two tunnels in the order the files happened to be walked in. Splitting one inventory across directories differently changed the finding's text, and where one pair of tunnels clashed on two elements, which element was named. -
netviz driftwrote the inventory path with backslashes on Windows. Every other path netviz prints uses forward slashes. -
netviz fmtraised a traceback on a documentnetviz validateaccepts. The round-trip parser the formatter uses resolves-._as a float and then fails to convert it; netviz's own loader reads the same scalar as the string it plainly is. It is now a diagnostic naming the file, like every other thing the formatter cannot read. -
Cache entries went missing when several netviz processes filled one cache at once. Every write went through a scratch file named after its destination, so two processes storing the same document wrote through the same file — on Windows, one of forty entries would end up never written at all, and stay a cache miss for good. Each writer now has its own.