Compare commits

..
15 Commits
Author SHA1 Message Date
Pouzor - Rémy JardientandGitHub 49ab20a9b9 Update FUNDING.yml 2026-09-09 11:04:38 +02:00
Pouzor 2f9889921e refactor(toolbar): drop the MD clipboard export from the canvas header
The button copied the node inventory to the clipboard as a markdown table —
a one-shot paste with nowhere to go now that Documentation holds written
records and exports them as files.

Removing it leaves generateMarkdownTable with no caller, so the util and its
suite go with it.

The HACS Toolbar carries the same button.

ha-relevant: yes
2026-09-09 02:56:16 +02:00
Pouzor 8dba259796 feat(docs): download one document, export the whole space as a zip
A document's body is already the whole markdown file, frontmatter included,
so the viewer's new Download button writes what it is holding — no round
trip. Export all does need the server: the tree carries summaries only.

The archive mirrors what the sidebar shows. Library folders become
directories with their own body as index.md; device, node and design
documents are placed by their link rather than by a parent, so each lands
in a directory named for its kind. Slugs are unique among siblings only,
so colliding paths are numbered rather than overwritten, and every segment
is sanitised — an exported title should not be able to write outside the
archive.

GET /documents/export is declared above /{document_id} so "export" is not
read as an id.

ha-relevant: no
2026-09-09 02:56:16 +02:00
Pouzor 783ca87acd chore(deps): bump js-yaml to 4.3.2 for GHSA-2883-xcg3-v3hh
The dependency-audit job fails on a high-severity advisory: js-yaml 4.0.0-4.3.1
does not limit CPU use for empty merge sources, so maxTotalMergeKeys can be
bypassed. 4.3.2 is the patched release.

Lockfile only — package.json already allows it at ^4.3.1, and nothing else in
the tree moved. `npm audit --omit=dev --audit-level=high` now reports 0
vulnerabilities.

homelable-hacs pins js-yaml at ^4.1.1 in frontend-src, inside the same
vulnerable range, so it needs the same bump in its own lockfile.

ha-relevant: maybe
2026-09-09 01:19:19 +02:00
Pouzor 71e78b4279 fix(mcp): carry the backend's error detail through to the client
raise_for_status raises httpx's own HTTPStatusError, whose message stops at the
status line and the URL. FastAPI's {"detail": ...} — the half that says what to
do about it — was dropped, so every rejection reached the calling AI as a bare
"Client error '422 Unprocessable Content'" with nothing to act on.

Replaces it with a BackendError naming the method, path, status and detail.
A structured detail (the approve-duplicate prompt answers with an object) is
serialized rather than lost, a non-JSON body falls back to its text, and the
whole thing is capped so an HTML error page can't land in the client's context.

ha-relevant: no
2026-09-09 01:19:19 +02:00
Pouzor ac8aea7467 feat(mcp): expose edge styling on create_edge and update_edge
The backend has always accepted these on EdgeCreate/EdgeUpdate; only the canvas
UI could set them, so an edge created over MCP came out with the edge type's
preset and no way to change it — the animation, colour and markers other edges
in the same canvas already use were unreachable.

Adds animated, custom_color, path_style, line_style, width_mult, marker_start,
marker_end, vlan_id and speed to both tools. The enums match what
normalize_animated / normalize_marker accept, so an advertised value can't
silently coerce to 'none'.

ha-relevant: no
2026-09-09 01:19:19 +02:00
Pouzor 08d85a40c1 fix(edges): reject an edge endpoint the node has no connection point for
React Flow draws nothing for an edge whose handle ID resolves to no element, so
such an edge persists in the DB and is simply invisible on the canvas, with no
error anywhere. Nothing could reach that state while only the UI wrote handles;
exposing source_handle/target_handle over MCP opens it up, which is the same
failure the shrink remap already prevents from the other direction.

- POST /edges and PATCH /edges/{id} answer 422 when a caller-supplied handle
  names a slot its node doesn't have — including a side it opted out of, since
  left/right default to no connection point at all.
- Handles outside the per-side vocabulary ('cluster-right' and friends) belong
  to their own renderer and are left alone.
- The auto-assignment corrects its own pick instead of raising: it always names
  a top/bottom slot 0, which a node that zeroed those sides no longer draws.
  A node with no connection point anywhere keeps the original.
- parse_side_handle in schemas/utils.py reads both the source form and the
  '<side>-t' target twin the auto-assignment stores.

ha-relevant: no
2026-09-09 01:19:19 +02:00
Pouzor e247483d9b feat(mcp): make connection points editable over MCP
Handle counts were readable through get_node but absent from update_node, and
create_edge took no handle arguments — the server always picked. Expose both,
and close the two backend gaps that opening them up would otherwise turn into
silent link loss.

- create_node/update_node take top/bottom/left/right_handles and
  show_port_numbers; create_edge and a new update_edge take
  source_handle/target_handle.
- New list_edges: get_canvas slims edges down to id/source/target/type/label,
  so the handles an edge sits on were unreadable through any tool.
- Clamp handle counts to 0..64 on every write path (create, update, canvas
  save). The renderer clamps at draw time, so an unclamped row drew a
  different node than it stored.
- Port the canvas store's shrink remap to the node PATCH route: an edge left
  on a handle that a count reduction deleted silently stops rendering.

Closes #435

ha-relevant: no
2026-09-09 01:19:19 +02:00
Pouzor acac515d18 feat(walkthrough): walk the tour through the Documentation section
The tour stopped at the canvas, so the section that holds every device page
and the Library was reachable only by finding the sidebar entry.

Three steps, between the rack and the styling ones: the section itself, the
template picker with wiki-links and history, and the device tree with its
generated headers and pivots. All backend-only — a standalone build has
nowhere to store a document.

Documentation replaces the whole main pane rather than opening a modal, so
`closeAll` puts the view back to the canvas before every step; without it the
following steps anchor on a toolbar that is no longer mounted.

ha-relevant: no
2026-09-08 00:20:18 +02:00
Pouzor - Rémy JardientandGitHub 5273189315 Update README.md 2026-09-07 18:57:24 +02:00
Pouzor - Rémy JardientandGitHub fa192beac9 Update README.md 2026-09-07 18:55:43 +02:00
Pouzor 7aa322da35 test(migrations): stop the pre-split backup test tracking the app version
`_backup_db` names its copy `homelab.db.back-<APP_VERSION>`, so the 3.4.1 bump
renamed the backup `init_db` takes from `back-3.3.2` to `back-3.4.1`. The test
hardcoded its post-split copies as `back-3.3.1` / `back-3.3.2`, which happened
to overwrite that backup while the version was 3.3.2; at 3.4.1 it survives,
still carrying the columns this very boot is about to drop, and wins the
newest-first scan in `_pre_split_backup`.

The fixture was also unlike a real install, where the current version's backup
is taken after the columns are already gone — they went in the 3.3.0 boot, not
this one. Copy a post-split database over it too, under whatever the version is,
and the test stops breaking on every bump.

`_pre_split_backup` is unchanged: preferring the newest backup that still holds
the columns is right, the drop boot's own backup included.

ha-relevant: no
2026-09-07 17:58:45 +02:00
Pouzor 574b84d843 chore: bump version to 3.4.1
ha-relevant: no
2026-09-07 17:08:19 +02:00
Pouzor 0ffece7a0d fix(inventory): widen the merge dialog so an IEEE is readable
The addresses are what the choice is made on, and `pve-proxmox-ai-113` was
clipped at the edge of a max-w-lg dialog. Wider, and the address line wraps
instead of truncating; the list grows with the viewport so a long selection
scrolls less.

ha-relevant: no
2026-09-07 17:05:05 +02:00
Pouzor 46dac1a89f feat(inventory): merge duplicate device rows, by hand or after an import
The inventory matched identity only when a row was created and never looked
again, so two rows describing one host — minted before either carried the
address that would have matched them — stayed separate forever. Documents,
racks and canvases then multiplied them.

Two repairs:

* `POST /scan/pending/merge` and a Merge action in the inventory's select
  mode: the user picks the survivor. The only way to collapse rows sharing
  no address at all, since a name is not an identity.
* `reconcile_duplicates`, run at the end of the Proxmox import and of a
  scan: collapses what a shared MAC proves. Never a shared IP alone, and
  never across two distinct IEEEs.

Merging keeps everything. The survivor holds every fact it had and fills its
gaps from the others; addresses, services, properties and sources union.
Canvas nodes, rack mounts, documents and mesh links are re-pointed before the
extras go, and each node's `display_view` gains the facts it never saw as
visible — a whitelist left alone would have rendered them hidden and made the
merge look like a no-op on the canvas.

Two bugs fell out on the way:

* `dedupe_nodes_by_device` deleted duplicate nodes while `rack_devices.node_id`
  and `documents.node_id` still named them. SQLite runs with foreign keys off,
  so the declared `ON DELETE SET NULL` never fired.
* The scanner's duplicate collapse deleted inventory rows outright, losing
  their facts and orphaning whatever drew them. It merges now; its guard for
  a second approved row is unchanged.

ha-relevant: maybe
2026-09-07 17:05:05 +02:00
53 changed files with 3306 additions and 207 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
buy_me_a_coffee: pouzor
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+12
View File
@@ -5,6 +5,18 @@ All notable changes to **Homelable** are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [3.4.1] - 2026-09-07
### Features
- Duplicate Device Inventory rows can be merged. A **Merge** action in the inventory's select mode lets you pick the survivor — the only way to collapse rows that share no address at all, since a name is not an identity — and `reconcile_duplicates` now runs at the end of a Proxmox import and of a scan to collapse what a shared MAC proves (never a shared IP alone, never across two distinct IEEEs). Merging keeps everything: the survivor fills its gaps from the others, addresses, services, properties and sources union, and canvas nodes, rack mounts, documents and mesh links are re-pointed before the extra rows go. (#424)
### Fixes
- `dedupe_nodes_by_device` deleted duplicate nodes while `rack_devices.node_id` and `documents.node_id` still named them; SQLite runs with foreign keys off, so the declared `ON DELETE SET NULL` never fired. (#424)
- The scanner's duplicate collapse merges instead of deleting inventory rows outright, which lost their facts and orphaned whatever drew them. Its guard against removing a second approved row is unchanged. (#424)
- The merge dialog is wide enough to read an IEEE. The addresses are what the choice is made on, and a long name was clipped at the edge of the dialog; the address line now wraps instead of truncating, and the list grows with the viewport. (#424)
## [3.4.0] - 2026-09-07
### Features
+5 -4
View File
@@ -1,7 +1,7 @@
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" />&nbsp;Homelable</h1>
<p align="center">
<strong>Self-hosted homelab infrastructure visualization, scanning &amp; live monitoring</strong>
<strong>Self-hosted homelab infrastructure visualization, documentation, scanning &amp; live monitoring</strong>
</p>
<p align="center">
@@ -21,6 +21,7 @@
<a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> ·
<a href="#installation">Installation</a> ·
<a href="#documentation">Documentation</a> ·
<a href="#rack-canvas">Rack Canvas</a> ·
<a href="#network-scanner">Network Scanner</a> ·
<a href="#zigbee2mqtt-import">Zigbee / Z-Wave</a> ·
@@ -31,7 +32,7 @@
## About
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee/zwave scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
Homelable also offers a healthcheck system through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
@@ -50,8 +51,8 @@ If you are running <img width="22" height="22" align="top" alt="New_Home_Assist
<p align="center">
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
<img alt="Homelable Device inventory" src="https://github.com/user-attachments/assets/f3903ac8-354d-4873-81ba-1914971890ed" />
<img alt="Rack display" src="https://github.com/user-attachments/assets/43273605-4f46-4163-8aea-8bf8a76a3f76" />
<img alt="Rack display in homelable" src="https://github.com/user-attachments/assets/43273605-4f46-4163-8aea-8bf8a76a3f76" />
<img alt="Documentation in Homelable" src="https://github.com/user-attachments/assets/4310bd8a-769b-4640-af84-085dc6d8b9ac" />
</p>
+1 -1
View File
@@ -1 +1 @@
3.4.0
3.4.1
+38 -1
View File
@@ -17,7 +17,7 @@ import re
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,6 +39,7 @@ from app.schemas.documents import (
SearchResponse,
)
from app.services import doc_backlinks, doc_search
from app.services.doc_export import ExportDoc, build_zip
from app.services.doc_template import (
BLOCKS,
TEMPLATE_DEVICE,
@@ -349,6 +350,42 @@ async def generated_block(
return {"block": block, "markdown": render_block(block, device, **context)}
@router.get("/export")
async def export_documents(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> Response:
"""Every document as a zip of `.md` files mirroring the tree.
Declared above `/{document_id}` so "export" is not read as an id.
"""
docs = (await db.execute(select(Document))).scalars().all()
archive = build_zip(
[
ExportDoc(
id=doc.id,
kind=doc.kind,
title=doc.title,
slug=doc.slug,
parent_id=doc.parent_id,
body=doc.body or "",
)
for doc in docs
]
)
filename = f"homelable-documentation-{_now():%Y%m%d}.zip"
return Response(
content=archive,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
# The browser reads the name from the header, and a cross-origin
# fetch cannot see it unless it is exposed.
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.get("/{document_id}", response_model=DocumentResponse)
async def get_document(
document_id: str,
+78 -3
View File
@@ -6,6 +6,7 @@ from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Design, Edge, Node
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
from app.schemas.utils import SIDES, clamp_handles, handle_count_field, parse_side_handle
router = APIRouter()
@@ -55,6 +56,74 @@ async def _auto_handles(
return "top", "bottom-t"
# ---------------------------------------------------------------------------
# Connection-point guards
# ---------------------------------------------------------------------------
def _side_count(node: Node, side: str) -> int:
"""A node's clamped connection-point count for one side."""
return clamp_handles(side, getattr(node, handle_count_field(side), None))
async def _reject_missing_handle(db: AsyncSession, node_id: str, handle: str | None, role: str) -> None:
"""422 when an endpoint names a connection point its node does not have.
React Flow draws nothing for an edge whose handle ID resolves to no element,
so such an edge would persist and be invisible on the canvas with no error
anywhere — the same failure `_remap_shrunk_handles` prevents when a count is
lowered. Callers that supply no handle are auto-assigned one instead and
never reach this.
"""
if handle is None:
return
parsed = parse_side_handle(handle)
if parsed is None:
# Not a per-side handle ('cluster-right' and friends): its own renderer
# owns it, and no count here describes it.
return
side, idx = parsed
node = await db.get(Node, node_id)
if node is None:
# An unknown node is not this guard's error to report; the edge routes
# have never checked that either endpoint exists.
return
count = _side_count(node, side)
if 0 <= idx < count:
return
raise HTTPException(
status_code=422,
detail=(
f"{role}_handle '{handle}' does not exist: node {node_id} has {count} "
f"connection point(s) on its {side} side."
),
)
async def _existing_handle(db: AsyncSession, node_id: str, handle: str) -> str:
"""Keep an auto-assigned handle, or swap it for one the node actually has.
The auto-assignment below always names a top/bottom slot 0, which a node that
opted out of those sides no longer draws. The server picked it, not the
caller, so this corrects it silently rather than raising. A node with no
connection point at all keeps the original: there is nothing better to use,
and refusing to create the edge would lose more than it saves.
"""
node = await db.get(Node, node_id)
if node is None:
return handle
parsed = parse_side_handle(handle)
if parsed is not None:
side, idx = parsed
if 0 <= idx < _side_count(node, side):
return handle
suffix = "-t" if handle.endswith("-t") else ""
for side in SIDES:
if _side_count(node, side) > 0:
return f"{side}{suffix}"
return handle
@router.get("", response_model=list[EdgeResponse])
async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Edge]:
result = await db.execute(select(Edge))
@@ -75,12 +144,15 @@ async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: s
# Compares the canvas Y positions of both nodes so that the edge always exits
# the upstream node's bottom and enters the downstream node's top (or vice versa
# for reverse flows), matching the UI convention for top-to-bottom topologies.
await _reject_missing_handle(db, data["source"], data.get("source_handle"), "source")
await _reject_missing_handle(db, data["target"], data.get("target_handle"), "target")
if data.get("source_handle") is None or data.get("target_handle") is None:
auto_src, auto_tgt = await _auto_handles(db, data["source"], data["target"])
if data.get("source_handle") is None:
data["source_handle"] = auto_src
data["source_handle"] = await _existing_handle(db, data["source"], auto_src)
if data.get("target_handle") is None:
data["target_handle"] = auto_tgt
data["target_handle"] = await _existing_handle(db, data["target"], auto_tgt)
edge = Edge(**data)
db.add(edge)
@@ -105,7 +177,10 @@ async def update_edge(
edge = await db.get(Edge, edge_id)
if not edge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found")
for field, value in body.model_dump(exclude_unset=True).items():
sent = body.model_dump(exclude_unset=True)
await _reject_missing_handle(db, edge.source, sent.get("source_handle"), "source")
await _reject_missing_handle(db, edge.target, sent.get("target_handle"), "target")
for field, value in sent.items():
setattr(edge, field, value)
await db.commit()
await db.refresh(edge)
+52 -2
View File
@@ -1,13 +1,20 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Design, InventoryDevice, Node
from app.db.models import Design, Edge, InventoryDevice, Node
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
from app.schemas.utils import (
SIDES,
handle_count_field,
handle_id,
removed_handle_ids,
side_default,
)
from app.services.doc_links import unlink_documents
from app.services.inventory_sync import (
facts_from_payload,
@@ -21,6 +28,47 @@ from app.services.node_dedupe import find_duplicate_node
router = APIRouter()
# ---------------------------------------------------------------------------
# Connection-point helpers
# ---------------------------------------------------------------------------
async def _remap_shrunk_handles(db: AsyncSession, node: Node, sent: dict[str, Any]) -> None:
"""Move this node's edges off the connection points a shrink just deleted.
The canvas store does the same remap client-side (canvasStore.updateNode), so
the UI never hits this; an API client — the MCP write tools — lowers a handle
count without it, and React Flow silently drops an edge whose handle no longer
exists. Removed handles fall back to the side's slot-0 ID, or to 'bottom' when
the side went to zero and has no slot 0 left.
Call before the new counts are written to the node.
"""
fallbacks: dict[str, str] = {}
for side in SIDES:
field = handle_count_field(side)
new_count = sent.get(field)
if new_count is None:
continue
raw_old = getattr(node, field, None)
old_count = raw_old if isinstance(raw_old, int) else side_default(side)
if new_count >= old_count:
continue
fallback = 'bottom' if new_count == 0 else handle_id(side, 0)
for hid in removed_handle_ids(side, old_count, new_count):
fallbacks[hid] = fallback
if not fallbacks:
return
result = await db.execute(select(Edge).where(or_(Edge.source == node.id, Edge.target == node.id)))
for edge in result.scalars().all():
if edge.source == node.id and edge.source_handle in fallbacks:
edge.source_handle = fallbacks[edge.source_handle]
if edge.target == node.id and edge.target_handle in fallbacks:
edge.target_handle = fallbacks[edge.target_handle]
# ---------------------------------------------------------------------------
# Auto-positioning helpers
# ---------------------------------------------------------------------------
@@ -156,6 +204,8 @@ async def update_node(
# Dropped rather than rejected so the rest of the edit still lands.
if sent.get("parent_id") == node_id:
sent.pop("parent_id")
# Before the new counts land: the old ones are what says which handles go away.
await _remap_shrunk_handles(db, node, sent)
for field, value in node_columns(sent).items():
setattr(node, field, value)
# The user edited the device, not just its drawing: push the facts down.
+7
View File
@@ -33,6 +33,7 @@ from app.schemas.proxmox import (
ProxmoxTestConnectionResponse,
)
from app.schemas.scan import ScanRunResponse
from app.services.device_merge import reconcile_duplicates
from app.services.discovery_sources import add_source
from app.services.inventory_sync import attach_device_ids
from app.services.mac_utils import normalize_mac
@@ -321,6 +322,12 @@ async def _persist_pending_import(
en.left_handles = max(en.left_handles or 0, 1)
en.right_handles = max(en.right_handles or 0, 1)
# Fold in any row this import just proved to be the same device: a guest
# matched here by its synthetic ieee can share a MAC or an IP with a row an
# earlier scan (or an older canvas node) minted before either side carried
# the address that would have matched them.
await reconcile_duplicates(db)
links_recorded = await _replace_links(db, edges_raw, cluster_pairs)
await db.commit()
+47
View File
@@ -28,6 +28,7 @@ from app.schemas.scan import (
InventoryDeviceUpdate,
ScanRunResponse,
)
from app.services.device_merge import merge_devices
from app.services.discovery_sources import add_source
from app.services.doc_links import unlink_documents
from app.services.inventory_sync import find_device_for, merge_properties, merge_services
@@ -117,6 +118,13 @@ def merge_mac_property(
return out
class MergeDevicesRequest(BaseModel):
"""Which inventory rows fold into which. ``winner_id`` is the survivor."""
winner_id: str
loser_ids: list[str]
class BulkActionRequest(BaseModel):
device_ids: list[str]
# Target design for approved nodes. Falls back to the first design when
@@ -621,6 +629,45 @@ async def list_proxmox_children(
return await _with_canvas_counts(db, list(result.scalars().all()))
@router.post("/pending/merge", response_model=InventoryDeviceResponse)
async def merge_inventory_devices(
payload: MergeDevicesRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> InventoryDevice:
"""Fold several inventory rows into one, keeping ``winner_id``.
The manual half of the duplicate repair. Identity matching only runs when a
row is created, so two rows describing one host — minted before either
carried the address that would have matched them — stay separate forever.
Where they share no address at all, no automatic rule can join them safely
(a name is not an identity), and this is the only way to say "these are the
same machine".
Non-destructive: facts are unioned onto the survivor and every canvas node,
rack mount and document is re-pointed at it before the extras go.
"""
winner = await db.get(InventoryDevice, payload.winner_id)
if winner is None:
raise HTTPException(status_code=404, detail="Device not found")
loser_ids = [i for i in dict.fromkeys(payload.loser_ids) if i != winner.id]
if not loser_ids:
raise HTTPException(status_code=400, detail="Select at least two devices to merge")
losers = (
await db.execute(select(InventoryDevice).where(InventoryDevice.id.in_(loser_ids)))
).scalars().all()
missing = set(loser_ids) - {row.id for row in losers}
if missing:
raise HTTPException(status_code=404, detail="Device not found")
await merge_devices(db, winner, list(losers))
await db.commit()
await db.refresh(winner)
return (await _with_canvas_counts(db, [winner]))[0]
@router.post("/pending/bulk-approve", response_model=dict)
async def bulk_approve_devices(
payload: BulkActionRequest,
+12 -2
View File
@@ -1,10 +1,10 @@
from typing import Any
from pydantic import BaseModel, field_validator, model_validator
from pydantic import BaseModel, ValidationInfo, field_validator, model_validator
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
from app.schemas.utils import normalize_animated, normalize_marker
from app.schemas.utils import clamp_handles, normalize_animated, normalize_marker
class NodeSave(BaseModel):
@@ -54,6 +54,16 @@ class NodeSave(BaseModel):
pos_x: float = 0
pos_y: float = 0
# Same 0..64 clamp the create/update schemas apply, so a full-canvas save
# cannot store a count the renderer would silently narrow.
@field_validator('bottom_handles', 'top_handles', 'left_handles', 'right_handles', mode='before')
@classmethod
def clamp_handle_count(cls, v: object, info: ValidationInfo) -> object:
if v is None:
return None
side = (info.field_name or '').removesuffix('_handles')
return clamp_handles(side, v)
@model_validator(mode="after")
def drop_self_parent(self) -> "NodeSave":
"""A node can never be its own parent.
+27 -1
View File
@@ -1,7 +1,9 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
from pydantic import BaseModel, ValidationInfo, field_validator
from app.schemas.utils import clamp_handles
class NodeBase(BaseModel):
@@ -39,6 +41,18 @@ class NodeBase(BaseModel):
left_handles: int = 0
right_handles: int = 0
# Connection-point counts are clamped to 0..64 here rather than in the route:
# the frontend clamps them again at render (handleUtils.clampHandles), so an
# unclamped row would draw a different node than it stores. See #435 — the MCP
# write tools reach these fields without the canvas UI's own bounds.
@field_validator('bottom_handles', 'top_handles', 'left_handles', 'right_handles', mode='before')
@classmethod
def clamp_handle_count(cls, v: object, info: ValidationInfo) -> object:
if v is None:
return None
side = (info.field_name or '').removesuffix('_handles')
return clamp_handles(side, v)
class NodeCreate(NodeBase):
# Override pos_x/pos_y so callers can omit them; None signals "auto-place".
@@ -85,6 +99,18 @@ class NodeUpdate(BaseModel):
left_handles: int | None = None
right_handles: int | None = None
# Connection-point counts are clamped to 0..64 here rather than in the route:
# the frontend clamps them again at render (handleUtils.clampHandles), so an
# unclamped row would draw a different node than it stores. See #435 — the MCP
# write tools reach these fields without the canvas UI's own bounds.
@field_validator('bottom_handles', 'top_handles', 'left_handles', 'right_handles', mode='before')
@classmethod
def clamp_handle_count(cls, v: object, info: ValidationInfo) -> object:
if v is None:
return None
side = (info.field_name or '').removesuffix('_handles')
return clamp_handles(side, v)
class NodeResponse(NodeBase):
id: str
+85
View File
@@ -1,3 +1,7 @@
import math
import re
def normalize_animated(v: object) -> str:
"""Normalize legacy bool/int animated values to string mode ('none'/'snake'/'flow')."""
if v is True or v == 1 or v == '1':
@@ -25,3 +29,84 @@ def normalize_marker(v: object) -> str:
if isinstance(v, str) and v in MARKER_SHAPES:
return v
return 'none'
# ---------------------------------------------------------------------------
# Connection points (React Flow handles)
# ---------------------------------------------------------------------------
# Mirrors frontend/src/utils/handleUtils.ts — keep the two in step.
#
# Handle IDs: slot 0 = the bare side name ('top' | 'bottom' | 'left' | 'right')
# slot N >= 1 = '{side}-{N + 1}' (e.g. 'bottom-2', 'left-3')
#
# Slot 0 keeps the bare name so edges saved before the multi-handle expansion
# stay valid. Invisible target handles carry a '-t' suffix on the frontend only;
# what is stored on an edge is always the source form.
SIDES = ('top', 'bottom', 'left', 'right')
MIN_HANDLES = 0
MAX_HANDLES = 64
def side_default(side: str) -> int:
"""Count a side falls back to when the node carries no explicit value.
Top/bottom default to their historical single handle; left/right to none, so
an existing diagram gains no side handles unless the user opts in.
"""
return 1 if side in ('top', 'bottom') else 0
def handle_count_field(side: str) -> str:
"""The `nodes` column storing a side's handle count."""
return f"{side}_handles"
def clamp_handles(side: str, n: object) -> int:
"""Clamp a raw count into 0..64.
A non-numeric or non-finite value falls back to the side default, so a
corrupt field still yields the historical count; an explicit 0 is honoured.
"""
if isinstance(n, bool) or not isinstance(n, int | float):
return side_default(side)
if isinstance(n, float) and not math.isfinite(n):
return side_default(side)
return max(MIN_HANDLES, min(MAX_HANDLES, math.floor(n)))
def handle_id(side: str, idx: int) -> str:
"""The source handle ID at a given slot index for a side."""
return side if idx == 0 else f"{side}-{idx + 1}"
def removed_handle_ids(side: str, old_count: int, new_count: int) -> set[str]:
"""The source handle IDs dropped when a side shrinks from old to new count."""
return {handle_id(side, i) for i in range(new_count, old_count)}
# A side handle ID, in either the source form ('left', 'left-3') or the
# invisible target form the frontend renders alongside it ('left-t', 'left-3-t').
_SIDE_HANDLE_RE = re.compile(r'^(top|bottom|left|right)(?:-(\d+))?(?:-t)?$')
def parse_side_handle(handle: str) -> tuple[str, int] | None:
"""Split a side handle ID into (side, slot index), or None when it isn't one.
A handle outside this vocabulary is left for its owner to interpret — the
'cluster-*' IDs a specialized node renderer draws are not per-side handles
and must not be validated against a side's count.
The numbered form is 2-based, because slot 0 keeps the bare side name: an
ID ending in '-0' or '-1' is therefore one the canvas never emits, and is
reported as slot -1 so that no count can satisfy it.
"""
m = _SIDE_HANDLE_RE.match(handle)
if m is None:
return None
side, slot = m.group(1), m.group(2)
if slot is None:
return side, 0
idx = int(slot) - 1
return side, (idx if idx >= 1 else -1)
+447
View File
@@ -0,0 +1,447 @@
"""Fold several `device_inventory` rows describing one host into a single row.
The inventory matches on identity *when a row is created* (``find_device_for``:
ieee > ip > mac) and never looks again. A row minted while its facts were still
incomplete — a canvas node saved before anyone typed its IP, a legacy node the
3.3.0 backfill could only see by label — therefore stays a second row forever,
even once the missing address arrives and both rows plainly describe the same
machine. This module is the repair for that, in two shapes:
* :func:`merge_devices` — the user picks the survivor and what folds into it.
The only way to collapse rows that share nothing an automatic rule can trust:
a name is not an identity.
* :func:`reconcile_duplicates` — run after an import, collapses only what a
shared MAC proves. Never a shared IP alone: that is not an identity.
Merging is non-destructive by construction: facts are unioned, and every
reference (`nodes`, `rack_devices`, `documents`, mesh links) is re-pointed onto
the survivor *before* the extras go. That is what separates it from the
scanner's older collapse, which deleted duplicate rows outright and left canvas
nodes naming a row that no longer existed — SQLite runs with foreign keys off
here, so the declared ``ON DELETE SET NULL`` never fires.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Document, InventoryDevice, InventoryDeviceLink, Node, RackDevice
from app.services.discovery_sources import add_source
from app.services.inventory_sync import (
VIEW_LISTS,
ip_tokens,
merge_properties,
merge_services,
view_of_device,
)
from app.services.node_dedupe import dedupe_nodes_by_device
logger = logging.getLogger(__name__)
# Scalars the survivor fills from a loser when — and only when — it has none of
# its own. `ip`, `mac`, the lists and the timestamps are merged by rule below.
_FILL_SCALARS = (
"hostname",
"os",
"suggested_type",
"discovery_source",
"friendly_name",
"device_subtype",
"model",
"vendor",
"lqi",
"label",
"type",
"notes",
"cpu_count",
"cpu_model",
"ram_gb",
"disk_gb",
"check_method",
"check_target",
"response_time_ms",
"rack_faceplate_id",
"rack_u_height",
"rack_col_span",
"rack_color",
"rack_ports",
)
def _blank(value: Any) -> bool:
return value is None or value == ""
def _newest(left: datetime | None, right: datetime | None) -> datetime | None:
if left is None or right is None:
return left or right
return max(left, right)
def _merged_ip(winner: str | None, loser: str | None) -> str | None:
"""Union of both address lists, the survivor's own addresses first.
A device legitimately holds several addresses (a management VLAN, a second
NIC), and dropping the loser's would undo the very match that identified it
— the next import would mint the duplicate again.
"""
tokens = ip_tokens(winner)
for token in ip_tokens(loser):
if token not in tokens:
tokens.append(token)
return ", ".join(tokens) or None
def _merged_status(winner: str, losers: list[InventoryDevice]) -> str:
"""The lifecycle state the survivor carries.
``approved`` wins over everything: a canvas node or a rack mount is about to
point at this row, and a row that is pending or hidden would file a drawn
device back in the discovery queue or make it vanish from the inventory.
"""
if winner == "approved" or any(loser.status == "approved" for loser in losers):
return "approved"
return winner
async def _merge_documents(db: AsyncSession, winner: InventoryDevice, loser_ids: list[str]) -> int:
"""Re-point the losers' documents, or orphan them. Returns how many orphaned.
``documents.device_id`` is unique per device (a partial index built in
``init_db``), so only one document can describe the survivor. The oldest of
the losers' is adopted when the survivor has none; the rest are unlinked
rather than deleted — what the user wrote outlives the row it described, and
an orphan keeps its denormalized title and can be re-linked from the Library.
"""
docs = (
await db.execute(
select(Document)
.where(Document.device_id.in_(loser_ids))
.order_by(Document.created_at, Document.id)
)
).scalars().all()
if not docs:
return 0
has_own = (
await db.execute(select(Document.id).where(Document.device_id == winner.id).limit(1))
).scalar_one_or_none()
orphaned = 0
for i, doc in enumerate(docs):
if i == 0 and has_own is None:
doc.device_id = winner.id
continue
doc.device_id = None
orphaned += 1
return orphaned
async def _show_merged_facts(db: AsyncSession, winner: InventoryDevice) -> int:
"""Make the survivor's facts visible on every canvas that draws it.
A node's ``display_view`` is a whitelist: ``apply_view`` appends anything the
view does not list *hidden*, which is what keeps a service a later scan found
off canvases nobody has enabled it on. After a merge that rule works against
the user — a node that came from the poorer row lists only the poorer row's
keys, so every service and property it just gained would arrive invisible,
and the canvas would look exactly as bare as before the merge.
So each node drawing the survivor has the keys missing from its view appended
as shown. Only *missing* keys: a fact this canvas listed as hidden was hidden
on purpose and stays that way. Returns the number of nodes touched.
"""
nodes = (
await db.execute(select(Node).where(Node.device_id == winner.id))
).scalars().all()
seeds = view_of_device(winner)
touched = 0
for node in nodes:
view = dict(node.display_view or {})
changed = False
for kind in VIEW_LISTS:
entries = view.get(kind)
if not isinstance(entries, list):
# No view at all for this list — `apply_view` already shows
# everything the row holds, merged facts included.
continue
listed = {str(e.get("key")) for e in entries if isinstance(e, dict)}
missing = [entry for entry in seeds[kind] if entry["key"] not in listed]
if missing:
view[kind] = [*entries, *missing]
changed = True
if changed:
# Reassign rather than mutate: a JSON column tracks identity, not
# in-place edits, so a mutated dict would never be written back.
node.display_view = view
touched += 1
return touched
async def _refresh_rack_mounts(db: AsyncSession, winner: InventoryDevice, losers: list[InventoryDevice]) -> None:
"""Point the losers' rack mounts at the survivor and refresh what they copied.
A mount denormalizes the device's ``label`` so a rack still renders after an
inventory purge. Re-pointing alone would leave it printing the name of a row
that no longer exists, so a copy that still matches the loser is refreshed to
the survivor's name — a label the user typed on the mount itself does not
match and is left alone. The faceplate, its size and its ports are overlaid
from the inventory row on load, and the merge already filled any the survivor
lacked, so the plate keeps rendering as it did.
"""
loser_ids = [row.id for row in losers]
mounts = (
await db.execute(select(RackDevice).where(RackDevice.device_id.in_(loser_ids)))
).scalars().all()
stale = {
name
for row in losers
for name in (row.label, row.friendly_name, row.hostname)
if name
}
winner_name = winner.label or winner.friendly_name or winner.hostname
for mount in mounts:
mount.device_id = winner.id
if winner_name and mount.label in stale:
mount.label = winner_name
async def _rewrite_links(db: AsyncSession, winner_ieee: str, loser_ieees: list[str]) -> None:
"""Move mesh/Proxmox links off the losers' IEEEs onto the survivor's.
``device_inventory_links`` addresses endpoints by IEEE, not by row id, so a
link left on a merged-away address stops resolving to anything. Rewriting
can produce a self-link (both endpoints were the same device) or a duplicate
pair; both are dropped.
"""
if not loser_ieees:
return
links = (
await db.execute(
select(InventoryDeviceLink).where(
or_(
InventoryDeviceLink.source_ieee.in_(loser_ieees),
InventoryDeviceLink.target_ieee.in_(loser_ieees),
)
)
)
).scalars().all()
for link in links:
if link.source_ieee in loser_ieees:
link.source_ieee = winner_ieee
if link.target_ieee in loser_ieees:
link.target_ieee = winner_ieee
await db.flush()
seen: set[tuple[str, str, str]] = set()
all_links = (
await db.execute(
select(InventoryDeviceLink)
.where(
or_(
InventoryDeviceLink.source_ieee == winner_ieee,
InventoryDeviceLink.target_ieee == winner_ieee,
)
)
.order_by(InventoryDeviceLink.discovered_at, InventoryDeviceLink.id)
)
).scalars().all()
for link in all_links:
key = (link.source_ieee, link.target_ieee, link.discovery_source)
if link.source_ieee == link.target_ieee or key in seen:
await db.delete(link)
continue
seen.add(key)
async def merge_devices(
db: AsyncSession,
winner: InventoryDevice,
losers: list[InventoryDevice],
) -> dict[str, Any]:
"""Fold ``losers`` into ``winner``. Does not commit — the caller owns that.
The survivor keeps every fact it already has; a loser only fills a gap. That
rule is the whole safety of the operation: whichever row the user (or the
reconcile pass) picked, nothing it recorded is overwritten by an older or
less-curated copy, and nothing the extras recorded is lost — lists are
unioned, addresses are unioned, sources accumulate.
Returns counts for the caller to report.
"""
losers = [row for row in losers if row.id != winner.id]
if not losers:
return {
"merged": 0,
"device_id": winner.id,
"nodes_repointed": 0,
"documents_orphaned": 0,
"views_extended": 0,
}
loser_ids = [row.id for row in losers]
# Oldest first: where two losers both fill the same gap, the older sighting
# is the one that has survived longest without being contradicted.
for loser in sorted(losers, key=lambda d: (d.discovered_at, d.id)):
for field in _FILL_SCALARS:
if _blank(getattr(winner, field, None)):
value = getattr(loser, field, None)
if not _blank(value):
setattr(winner, field, value)
winner.ip = _merged_ip(winner.ip, loser.ip)
winner.mac = winner.mac or loser.mac
winner.services = merge_services(winner.services, loser.services)
winner.properties = merge_properties(winner.properties, loser.properties)
winner.show_hardware = winner.show_hardware or loser.show_hardware
for source in add_source(loser.discovery_sources, loser.discovery_source):
winner.discovery_sources = add_source(winner.discovery_sources, source)
if winner.status_live in (None, "", "unknown"):
winner.status_live = loser.status_live
winner.last_seen = _newest(winner.last_seen, loser.last_seen)
winner.last_scan = _newest(winner.last_scan, loser.last_scan)
# The device has been known since the earliest of the rows saw it.
if loser.discovered_at and winner.discovered_at:
winner.discovered_at = min(winner.discovered_at, loser.discovered_at)
winner.status = _merged_status(winner.status, losers)
nodes = await db.execute(
update(Node).where(Node.device_id.in_(loser_ids)).values(device_id=winner.id)
)
await _refresh_rack_mounts(db, winner, losers)
orphaned = await _merge_documents(db, winner, loser_ids)
await db.flush()
shown = await _show_merged_facts(db, winner)
loser_ieees = [row.ieee_address for row in losers if row.ieee_address]
adopted: str | None = None
if _blank(winner.ieee_address) and loser_ieees:
# `ieee_address` is UNIQUE, so the address can only move once the row
# holding it is gone — assigned after the delete below.
adopted = loser_ieees[0]
for loser in losers:
await db.delete(loser)
await db.flush()
if adopted is not None:
winner.ieee_address = adopted
await db.flush()
if winner.ieee_address:
await _rewrite_links(db, winner.ieee_address, loser_ieees)
# Two nodes on one canvas may now draw the survivor — that is exactly the
# same-design duplicate the node repair collapses.
await dedupe_nodes_by_device(db)
await db.flush()
logger.info(
"Inventory merge: %d row(s) folded into %s (%s)",
len(losers), winner.id, winner.label or winner.friendly_name or winner.ip,
)
return {
"merged": len(losers),
"device_id": winner.id,
"nodes_repointed": nodes.rowcount or 0,
"documents_orphaned": orphaned,
"views_extended": shown,
}
def _conflicting(left: InventoryDevice, right: InventoryDevice) -> bool:
"""True when two rows carry addresses that say they are different machines.
A distinct non-blank IEEE on each is decisive: two Proxmox guests (the
synthetic ``pve-{host}-{vmid}``) or two radios are never one device. A
distinct MAC says the same for hardware. Either one vetoes an automatic
merge — the user can still merge them by hand.
"""
if left.ieee_address and right.ieee_address and left.ieee_address.lower() != right.ieee_address.lower():
return True
return bool(left.mac and right.mac and left.mac != right.mac)
def _pick_winner(group: list[InventoryDevice]) -> InventoryDevice:
"""The row the others fold into.
An IEEE first: mesh links and the Proxmox host→guest graph are keyed on it,
and the import matches on it before anything else, so the row holding one is
the row every other writer will come back to. Then an approved row (it is
the one already drawn), then the oldest.
"""
return sorted(
group,
key=lambda d: (
0 if d.ieee_address else 1,
0 if d.status == "approved" else 1,
d.discovered_at,
d.id,
),
)[0]
def _auto_groups(rows: list[InventoryDevice]) -> list[list[InventoryDevice]]:
"""Group rows a shared MAC proves to be one device.
The MAC and nothing else. A shared IP is not enough on its own — a re-used
DHCP lease, two guests behind one NAT, or a container bridge address several
guests report all make one address describe several machines — and the
scanner already collapses what an IP does justify, under its own guard for
rows that are drawn on a canvas. Labels and hostnames are ignored outright:
two boxes are often called the same thing, and an automatic pass is not the
place to decide they are one. Whatever is left over is what the manual merge
is for.
A group is dropped whole when any pair inside it contradicts (see
:func:`_conflicting`) rather than guessing which member does not belong.
"""
by_mac: dict[str, list[InventoryDevice]] = {}
for row in rows:
if row.mac:
by_mac.setdefault(row.mac.lower(), []).append(row)
out = []
for group in by_mac.values():
if len(group) < 2:
continue
pairs = [(a, b) for i, a in enumerate(group) for b in group[i + 1:]]
if any(_conflicting(a, b) for a, b in pairs):
logger.info(
"Inventory reconcile: leaving %d row(s) sharing a MAC alone — "
"their IEEE says they are different devices (%s)",
len(group), ", ".join(row.id for row in group),
)
continue
out.append(group)
return out
async def reconcile_duplicates(db: AsyncSession) -> int:
"""Merge inventory rows a shared MAC proves to be one device. Does not commit.
Meant to run at the end of an import, where a row that was created blind may
have just gained the address that identifies it. Returns the number of rows
merged away.
Hidden rows are excluded on both sides: the user hid one deliberately, and
folding it into a visible row would put it back in front of them, while
folding a visible row into it would make a device they are using disappear.
"""
rows = (
await db.execute(
select(InventoryDevice)
.where(InventoryDevice.status != "hidden")
.order_by(InventoryDevice.discovered_at, InventoryDevice.id)
)
).scalars().all()
merged = 0
for group in _auto_groups(list(rows)):
winner = _pick_winner(group)
result = await merge_devices(db, winner, [row for row in group if row is not winner])
merged += int(result["merged"])
return merged
+119
View File
@@ -0,0 +1,119 @@
"""Export the documentation space as a zip of `.md` files.
`Document.body` is already the whole markdown file, YAML frontmatter included,
so exporting is a question of *where* each body lands, not what it contains —
nothing here rewrites a byte the user wrote.
The archive mirrors what the sidebar shows. The Library tree becomes real
directories, one per folder document; everything that is filed by a link rather
than by hand (a device, a piece of canvas furniture, a whole canvas) lands in a
flat directory named after its kind, because those are pivoted client-side and
have no single tree to mirror.
"""
from __future__ import annotations
import io
import re
import zipfile
from dataclasses import dataclass
# Kinds that are placed by their link instead of by `parent_id`, and the
# directory each one exports into.
LINKED_DIRS = {"device": "devices", "node": "nodes", "design": "designs"}
# A folder carries an index body of its own, and a directory cannot hold one —
# so it becomes a file inside the directory it opens.
FOLDER_INDEX = "index"
_UNSAFE = re.compile(r"[^a-z0-9._-]+")
@dataclass(frozen=True)
class ExportDoc:
"""The fields the layout needs, so the mapping stays testable without a DB."""
id: str
kind: str
title: str
slug: str
parent_id: str | None
body: str
def safe_segment(raw: str) -> str:
"""One path segment that cannot escape the archive or collide with a device.
Slugs come from `slugify` and are already tame, but a body can be exported
long after a title was renamed by hand, and `..` or a leading dot would be a
zip-slip waiting for whichever tool unpacks this.
"""
cleaned = _UNSAFE.sub("-", (raw or "").strip().lower()).strip("-.")
return cleaned or "untitled"
def _ancestors(doc: ExportDoc, by_id: dict[str, ExportDoc]) -> list[str]:
"""The folder segments above `doc`, outermost first.
A parent chain that loops — which the move guards prevent, but a hand-edited
database does not — stops at the first repeat rather than spinning.
"""
segments: list[str] = []
seen = {doc.id}
current = by_id.get(doc.parent_id or "")
while current is not None and current.id not in seen:
seen.add(current.id)
segments.append(safe_segment(current.slug or current.title))
current = by_id.get(current.parent_id or "")
segments.reverse()
return segments
def _path_for(doc: ExportDoc, by_id: dict[str, ExportDoc]) -> str:
name = safe_segment(doc.slug or doc.title)
directory = LINKED_DIRS.get(doc.kind)
if directory is not None:
return f"{directory}/{name}.md"
parts = _ancestors(doc, by_id)
if doc.kind == "folder":
# The folder's own body sits inside the directory it opens, so its
# children are siblings of it rather than of the folder file.
return "/".join([*parts, name, f"{FOLDER_INDEX}.md"])
return "/".join([*parts, f"{name}.md"])
def export_paths(docs: list[ExportDoc]) -> list[tuple[str, str]]:
"""`(path, body)` for every document, in a stable order and never colliding.
Slugs are only unique among siblings, and a linked document is a sibling of
the Library root while exporting into `devices/`, so two documents really can
want the same path. The second one gets a numbered name rather than
overwriting the first — a silently short export is the worst outcome here.
"""
by_id = {doc.id: doc for doc in docs}
taken: set[str] = set()
out: list[tuple[str, str]] = []
for doc in sorted(docs, key=lambda d: (d.kind, d.title.lower(), d.id)):
path = _path_for(doc, by_id)
if path.lower() in taken:
stem, _, extension = path.rpartition(".")
suffix = 2
while f"{stem}-{suffix}.{extension}".lower() in taken:
suffix += 1
path = f"{stem}-{suffix}.{extension}"
taken.add(path.lower())
out.append((path, doc.body or ""))
return out
def build_zip(docs: list[ExportDoc]) -> bytes:
"""The whole documentation space as one deflated archive, built in memory.
A homelab's worth of markdown is kilobytes; streaming it would buy nothing
and cost the caller a temporary file.
"""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for path, body in export_paths(docs):
archive.writestr(path, body)
return buffer.getvalue()
+25 -2
View File
@@ -15,10 +15,10 @@ from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Edge, Node
from app.db.models import Document, Edge, Node, RackDevice
from app.services.inventory_sync import find_device_for
logger = logging.getLogger(__name__)
@@ -143,6 +143,29 @@ async def dedupe_nodes_by_device(db: AsyncSession) -> int:
continue
seen_pairs.add(key)
# A rack mount reads live status through `node_id`, and a document may
# describe the node itself. Both are `SET NULL` in the schema and SQLite
# runs with foreign keys off here, so a duplicate deleted from under them
# would leave the column naming a node that no longer exists.
await db.execute(
update(RackDevice).where(RackDevice.node_id.in_(dup_ids)).values(node_id=canonical.id)
)
own_doc = (
await db.execute(select(Document.id).where(Document.node_id == canonical.id).limit(1))
).scalar_one_or_none()
doc_rows = (
await db.execute(
select(Document)
.where(Document.node_id.in_(dup_ids))
.order_by(Document.created_at, Document.id)
)
).scalars().all()
for i, doc in enumerate(doc_rows):
# `documents.node_id` is unique (partial index): only one document
# can describe the survivor. The rest are orphaned, not deleted —
# what the user wrote outlives the node it was attached to.
doc.node_id = canonical.id if i == 0 and own_doc is None else None
await db.flush()
for dup in dups:
await db.delete(dup)
+21 -6
View File
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.models import InventoryDevice, ScanRun
from app.services.device_merge import merge_devices, reconcile_duplicates
from app.services.discovery_sources import add_source
from app.services.fingerprint import fingerprint_ports, suggest_node_type
from app.services.http_probe import probe_open_ports
@@ -643,10 +644,12 @@ async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
for group in _group_by_shared_ip(list(rows)):
if len(group) < 2:
continue
_, dups = _collapse_targets(group)
for dup in dups:
await db.delete(dup)
deleted += 1
keep, dups = _collapse_targets(group)
if dups:
# Non-destructive, for the same reason as `process_host`: whatever
# drew the duplicate follows it into `keep`.
await merge_devices(db, keep, dups)
deleted += len(dups)
if deleted:
await db.commit()
return deleted
@@ -720,8 +723,12 @@ async def process_host(
# by earlier scans — except a second approved row, which keeps its
# own canvas links (see `_collapse_targets`).
keep, dups = _collapse_targets(existing_rows)
for dup in dups:
await db.delete(dup)
# Merge rather than delete: a duplicate row may be the one a canvas node
# or a rack mount points at, and SQLite runs with foreign keys off here
# so the declared `ON DELETE SET NULL` never fires. `merge_devices`
# re-points every reference onto `keep` and unions the facts first.
if dups:
await merge_devices(db, keep, dups)
keep.ip = keep.ip or ip # fill an IP a Proxmox import lacked
keep.mac = norm_mac or keep.mac
keep.hostname = host.get("hostname") or keep.hostname
@@ -857,6 +864,14 @@ async def run_scan(
else:
mdns_task.cancel()
# A scan is where a row first learns its MAC, so it is also where two
# rows can first be proven to be one device — fold those together before
# reporting the run done.
merged = await reconcile_duplicates(db)
if merged:
await db.commit()
logger.info("Scan %s: merged %d duplicate inventory row(s)", run_id, merged)
# Mark scan as done or cancelled
run = await db.get(ScanRun, run_id)
if run:
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
import app.db.database as database
import app.services.inventory_sync as inventory_sync
from app.core.config import APP_VERSION
# `create_all` under v3.2.0. NOT NULL is reproduced exactly where it was.
_NODES_320 = (
@@ -412,8 +413,10 @@ async def test_the_newest_backup_that_still_has_the_columns_is_the_one_read(db_3
shutil.copy2(db_path, pre_split)
await database.init_db() # drops the columns, and backs up under this version
# Post-split backups: newer, and unable to say who drew what.
for name in ("back-3.3.1", "back-3.3.2"):
# Post-split backups: newer, and unable to say who drew what. On a real
# install the current version's own backup is one of them — the columns went
# in an earlier boot, not this one — so overwrite the one `init_db` took.
for name in ("back-3.3.1", "back-3.3.2", f"back-{APP_VERSION}"):
shutil.copy2(db_path, db_path.parent / f"{db_path.name}.{name}")
assert database._pre_split_backup() == pre_split
+12
View File
@@ -119,6 +119,18 @@ async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, hea
assert node["right_handles"] == 4
async def test_save_canvas_clamps_per_side_handles(client: AsyncClient, headers: dict):
# Same 0..64 bound the create/update routes apply (#435): the renderer clamps
# at draw time, so a saved count above it would draw a node the row doesn't
# describe.
n1 = node_payload(label="Sw", type="switch", bottom_handles=5000, left_handles=-2)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
assert node["bottom_handles"] == 64
assert node["left_handles"] == 0
async def test_save_canvas_defaults_per_side_handles(client: AsyncClient, headers: dict):
# Nodes saved without the new fields fall back to top/bottom=1, left/right=0.
n1 = node_payload(label="Srv", type="server")
+405
View File
@@ -0,0 +1,405 @@
"""Tests for the inventory duplicate repair (app.services.device_merge).
Two shapes, one merge: the user picks the survivor by hand, or an import proves
two rows are one device by their shared MAC. What both must guarantee is that
nothing is lost — not a fact, not a canvas node, not a rack mount, not a
document, and not the *visibility* of the facts that just arrived on a canvas
that never saw them.
"""
from datetime import datetime, timezone
import pytest
from sqlalchemy import select
from app.db.models import (
Design,
Document,
InventoryDevice,
InventoryDeviceLink,
Node,
Rack,
RackDevice,
)
from app.services.device_merge import merge_devices, reconcile_duplicates
def _at(day: int) -> datetime:
return datetime(2026, 1, day, tzinfo=timezone.utc)
async def _design(db, name="d1"):
design = Design(name=name)
db.add(design)
await db.flush()
return design
async def _device(db, **kwargs):
kwargs.setdefault("discovered_at", _at(1))
device = InventoryDevice(**kwargs)
db.add(device)
await db.flush()
return device
async def _node(db, design, device, **kwargs):
node = Node(design_id=design.id, device_id=device.id, label=kwargs.pop("label", "n8n"),
type=kwargs.pop("type", "lxc"), **kwargs)
db.add(node)
await db.flush()
return node
async def _rack(db, design):
rack = Rack(design_id=design.id, name="R1", u_height=42)
db.add(rack)
await db.flush()
return rack
@pytest.mark.asyncio
async def test_merge_unions_facts_without_overwriting_the_survivor(db_session):
winner = await _device(
db_session, id="w", ip="192.168.1.62", mac="bc:24:11:95:af:8c",
hostname="n8n", label="n8n", vendor="Proxmox VE", status="pending",
services=[{"service_name": "ssh", "port": 22, "protocol": "tcp"}],
properties=[{"key": "VMID", "value": "130", "visible": True}],
discovery_source="arp", discovery_sources=["arp", "proxmox"],
discovered_at=_at(5),
)
loser = await _device(
db_session, id="l", ip="10.0.0.9", mac=None, hostname="ignored",
notes="runs the automations", cpu_count=4, status="approved",
services=[{"service_name": "http", "port": 5678, "protocol": "tcp"}],
properties=[{"key": "Owner", "value": "me", "visible": True}],
discovery_source="canvas", discovery_sources=["canvas"],
discovered_at=_at(2),
)
result = await merge_devices(db_session, winner, [loser])
await db_session.flush()
assert result["merged"] == 1
assert await db_session.get(InventoryDevice, "l") is None
# Established facts are never overwritten by the folded-in row...
assert winner.hostname == "n8n"
# ...but every gap it can fill, it fills.
assert winner.notes == "runs the automations"
assert winner.cpu_count == 4
# Addresses and lists union; sources accumulate.
assert winner.ip == "192.168.1.62, 10.0.0.9"
assert {s["service_name"] for s in winner.services} == {"ssh", "http"}
assert {p["key"] for p in winner.properties} == {"VMID", "Owner"}
assert set(winner.discovery_sources) == {"arp", "proxmox", "canvas"}
# Drawn on a canvas, so the survivor cannot go back to the pending queue.
assert winner.status == "approved"
# Known since the earliest of the two rows saw it.
assert winner.discovered_at == _at(2)
@pytest.mark.asyncio
async def test_merge_repoints_canvas_nodes_and_rack_mounts(db_session):
design = await _design(db_session)
other = await _design(db_session, name="d2")
winner = await _device(db_session, id="w", ip="192.168.1.62", status="approved")
loser = await _device(db_session, id="l", ip="192.168.1.62", label="n8n", status="approved")
kept = await _node(db_session, design, winner, pos_x=10, pos_y=20)
moved = await _node(db_session, other, loser, pos_x=300, pos_y=400)
rack = await _rack(db_session, design)
mount = RackDevice(design_id=design.id, rack_id=rack.id, device_id=loser.id, label="n8n")
db_session.add(mount)
await db_session.flush()
await merge_devices(db_session, winner, [loser])
await db_session.flush()
# The node on the other canvas keeps its position and now draws the survivor.
await db_session.refresh(moved)
assert moved.device_id == "w"
assert (moved.pos_x, moved.pos_y) == (300, 400)
assert (await db_session.get(Node, kept.id)).device_id == "w"
# The mount follows too — a rack that rendered before still renders.
await db_session.refresh(mount)
assert mount.device_id == "w"
@pytest.mark.asyncio
async def test_merge_shows_the_new_facts_on_a_canvas_that_never_saw_them(db_session):
"""The point of the merge: a poorer node must gain the richer row's facts.
`display_view` is a whitelist — without extending it the folded-in services
and properties would render hidden and the canvas would look unchanged.
"""
design = await _design(db_session)
winner = await _device(
db_session, id="w", ip="192.168.1.62",
services=[{"service_name": "ssh", "port": 22, "protocol": "tcp"}],
properties=[{"key": "VMID", "value": "130", "visible": True}],
)
loser = await _device(
db_session, id="l", ip="192.168.1.62",
services=[{"service_name": "http", "port": 5678, "protocol": "tcp"}],
properties=[],
)
poor = await _node(db_session, design, loser)
poor.display_view = {
"services": [{"key": "5678|tcp|http", "visible": True}],
"properties": [],
}
await db_session.flush()
await merge_devices(db_session, winner, [loser])
await db_session.flush()
await db_session.refresh(poor)
services = poor.display_view["services"]
assert [e["visible"] for e in services] == [True, True]
assert {e["key"] for e in services} == {"5678|tcp|http", "22|tcp|ssh"}
# A property list this canvas had never seen picks up the survivor's.
assert [e["key"] for e in poor.display_view["properties"]] == ["vmid"]
@pytest.mark.asyncio
async def test_merge_keeps_a_deliberately_hidden_fact_hidden(db_session):
design = await _design(db_session)
winner = await _device(
db_session, id="w", ip="192.168.1.62",
services=[{"service_name": "ssh", "port": 22, "protocol": "tcp"}],
)
loser = await _device(db_session, id="l", ip="192.168.1.62", services=[])
node = await _node(db_session, design, loser)
node.display_view = {"services": [{"key": "22|tcp|ssh", "visible": False}], "properties": []}
await db_session.flush()
await merge_devices(db_session, winner, [loser])
await db_session.flush()
await db_session.refresh(node)
assert node.display_view["services"] == [{"key": "22|tcp|ssh", "visible": False}]
@pytest.mark.asyncio
async def test_merge_collapses_two_nodes_that_now_draw_one_device(db_session):
"""Both rows drawn on the *same* canvas: one node survives, the oldest."""
design = await _design(db_session)
winner = await _device(db_session, id="w", ip="192.168.1.62", status="approved")
loser = await _device(db_session, id="l", ip="192.168.1.62", status="approved")
keep = await _node(db_session, design, winner, pos_x=10, pos_y=20)
await _node(db_session, design, loser, pos_x=99, pos_y=99)
await merge_devices(db_session, winner, [loser])
await db_session.flush()
nodes = (
await db_session.execute(select(Node).where(Node.design_id == design.id))
).scalars().all()
assert [n.id for n in nodes] == [keep.id]
assert (nodes[0].pos_x, nodes[0].pos_y) == (10, 20)
@pytest.mark.asyncio
async def test_a_rack_mount_survives_the_node_collapse(db_session):
"""`rack_devices.node_id` must not be left naming a deleted duplicate node."""
design = await _design(db_session)
winner = await _device(db_session, id="w", ip="192.168.1.62", status="approved")
loser = await _device(db_session, id="l", ip="192.168.1.62", status="approved")
keep = await _node(db_session, design, winner)
dup = await _node(db_session, design, loser)
rack = await _rack(db_session, design)
mount = RackDevice(
design_id=design.id, rack_id=rack.id, device_id=loser.id, node_id=dup.id, label="n8n"
)
db_session.add(mount)
await db_session.flush()
await merge_devices(db_session, winner, [loser])
await db_session.flush()
await db_session.refresh(mount)
assert mount.device_id == "w"
assert mount.node_id == keep.id
assert await db_session.get(Node, dup.id) is None
@pytest.mark.asyncio
async def test_merge_adopts_a_document_or_orphans_it(db_session):
winner = await _device(db_session, id="w", ip="192.168.1.62")
loser = await _device(db_session, id="l", ip="192.168.1.62")
doc = Document(title="n8n", slug="n8n", device_id=loser.id, body="# n8n")
db_session.add(doc)
await db_session.flush()
result = await merge_devices(db_session, winner, [loser])
await db_session.flush()
await db_session.refresh(doc)
# No document on the survivor, so the loser's is adopted rather than orphaned.
assert doc.device_id == "w"
assert result["documents_orphaned"] == 0
@pytest.mark.asyncio
async def test_a_second_document_is_orphaned_not_deleted(db_session):
winner = await _device(db_session, id="w", ip="192.168.1.62")
loser = await _device(db_session, id="l", ip="192.168.1.62")
mine = Document(title="n8n", slug="n8n", device_id=winner.id, body="kept")
theirs = Document(title="n8n (dup)", slug="n8n-dup", device_id=loser.id, body="written by hand")
db_session.add_all([mine, theirs])
await db_session.flush()
result = await merge_devices(db_session, winner, [loser])
await db_session.flush()
await db_session.refresh(theirs)
assert result["documents_orphaned"] == 1
assert theirs.device_id is None
assert theirs.body == "written by hand" # survives as an orphan
@pytest.mark.asyncio
async def test_merge_moves_mesh_links_onto_the_survivor_ieee(db_session):
winner = await _device(db_session, id="w", ieee_address="pve-pve1-130", ip="192.168.1.62")
loser = await _device(db_session, id="l", ieee_address="0xAABB", ip="192.168.1.62")
db_session.add(InventoryDeviceLink(source_ieee="pve-host", target_ieee="0xAABB"))
db_session.add(InventoryDeviceLink(source_ieee="0xAABB", target_ieee="0xCCDD"))
await db_session.flush()
await merge_devices(db_session, winner, [loser])
await db_session.flush()
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
pairs = {(link.source_ieee, link.target_ieee) for link in links}
assert pairs == {("pve-host", "pve-pve1-130"), ("pve-pve1-130", "0xCCDD")}
@pytest.mark.asyncio
async def test_the_survivor_adopts_an_ieee_it_lacks(db_session):
winner = await _device(db_session, id="w", ip="192.168.1.62", ieee_address=None)
loser = await _device(db_session, id="l", ip="192.168.1.62", ieee_address="pve-pve1-130")
await merge_devices(db_session, winner, [loser])
await db_session.flush()
assert winner.ieee_address == "pve-pve1-130"
@pytest.mark.asyncio
async def test_reconcile_merges_rows_sharing_a_mac(db_session):
"""The reported case: a Proxmox row and a canvas row, same NIC."""
scanned = await _device(
db_session, id="scanned", ip="192.168.1.62", mac="bc:24:11:95:af:8c",
ieee_address="pve-proxmox-ai-130", status="pending", discovered_at=_at(1),
)
from_canvas = await _device(
db_session, id="canvas", ip="192.168.1.62", mac="bc:24:11:95:af:8c",
label="n8n", status="approved", discovered_at=_at(20),
)
merged = await reconcile_duplicates(db_session)
await db_session.flush()
assert merged == 1
# The IEEE-bearing row wins: the Proxmox import and the link graph key on it.
assert await db_session.get(InventoryDevice, from_canvas.id) is None
assert (await db_session.get(InventoryDevice, scanned.id)).label == "n8n"
@pytest.mark.asyncio
async def test_reconcile_leaves_rows_that_only_share_an_ip(db_session):
"""An IP is not an identity — a re-used lease must not merge two machines."""
await _device(db_session, id="a", ip="192.168.1.62", mac="aa:aa:aa:aa:aa:aa")
await _device(db_session, id="b", ip="192.168.1.62", mac="bb:bb:bb:bb:bb:bb")
assert await reconcile_duplicates(db_session) == 0
assert len((await db_session.execute(select(InventoryDevice))).scalars().all()) == 2
@pytest.mark.asyncio
async def test_reconcile_leaves_two_proxmox_guests_sharing_a_mac(db_session):
"""Distinct synthetic IEEEs are decisive: two guests are never one device."""
await _device(db_session, id="a", mac="bc:24:11:95:af:8c", ieee_address="pve-pve1-130")
await _device(db_session, id="b", mac="bc:24:11:95:af:8c", ieee_address="pve-pve1-131")
assert await reconcile_duplicates(db_session) == 0
assert len((await db_session.execute(select(InventoryDevice))).scalars().all()) == 2
@pytest.mark.asyncio
async def test_reconcile_never_touches_a_hidden_row(db_session):
"""A hidden row was hidden on purpose — merging it would resurrect it."""
await _device(db_session, id="visible", mac="bc:24:11:95:af:8c", status="pending")
await _device(db_session, id="hidden", mac="bc:24:11:95:af:8c", status="hidden")
assert await reconcile_duplicates(db_session) == 0
hidden = await db_session.get(InventoryDevice, "hidden")
assert hidden is not None and hidden.status == "hidden"
@pytest.mark.asyncio
async def test_reconcile_is_idempotent(db_session):
await _device(db_session, id="a", mac="bc:24:11:95:af:8c", ip="192.168.1.62")
await _device(db_session, id="b", mac="bc:24:11:95:af:8c", ip="192.168.1.62")
assert await reconcile_duplicates(db_session) == 1
await db_session.flush()
assert await reconcile_duplicates(db_session) == 0
# --- The route -----------------------------------------------------------
@pytest.mark.asyncio
async def test_merge_route_returns_the_survivor(client, headers, db_session):
await _device(db_session, id="w", ip="192.168.1.62", label="n8n", status="approved")
await _device(db_session, id="l", hostname="n8n", notes="the good notes")
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/merge",
json={"winner_id": "w", "loser_ids": ["l"]},
headers=headers,
)
assert res.status_code == 200
body = res.json()
assert body["id"] == "w"
assert body["notes"] == "the good notes"
assert await db_session.get(InventoryDevice, "l") is None
@pytest.mark.asyncio
async def test_merge_route_rejects_a_single_device(client, headers, db_session):
await _device(db_session, id="w", ip="192.168.1.62")
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/merge",
json={"winner_id": "w", "loser_ids": ["w"]},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_merge_route_404s_on_an_unknown_row(client, headers, db_session):
await _device(db_session, id="w", ip="192.168.1.62")
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/merge",
json={"winner_id": "w", "loser_ids": ["nope"]},
headers=headers,
)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_merge_route_requires_auth(client, db_session):
res = await client.post(
"/api/v1/scan/pending/merge", json={"winner_id": "w", "loser_ids": ["l"]}
)
assert res.status_code in (401, 403)
+233
View File
@@ -0,0 +1,233 @@
"""Exporting the documentation space as a zip of `.md` files.
The promise is that unzipping gives back what the sidebar shows, byte for byte:
the Library tree as directories, everything filed by a link in a directory named
after its kind, and every body exactly as it was written.
"""
import io
import zipfile
import pytest
from httpx import AsyncClient
from app.services.doc_export import ExportDoc, build_zip, export_paths, safe_segment
def _doc(id: str, kind: str = "page", *, title: str = "", slug: str = "", parent_id=None, body: str = "") -> ExportDoc:
return ExportDoc(
id=id,
kind=kind,
title=title or id,
slug=slug or id,
parent_id=parent_id,
body=body,
)
async def _device(client: AsyncClient, headers: dict, **body) -> dict:
payload = {"label": "nas-01", "hostname": "nas-01.lan", "ip": "192.168.1.20", "discovery_source": "manual", **body}
res = await client.post("/api/v1/scan/pending", json=payload, headers=headers)
assert res.status_code in (200, 201), res.text
return res.json()
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
res = await client.post("/api/v1/documents", json={"title": "Page", **body}, headers=headers)
assert res.status_code == 201, res.text
return res.json()
def _entries(payload: bytes) -> dict[str, str]:
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
return {name: archive.read(name).decode() for name in archive.namelist()}
# ── path layout ─────────────────────────────────────────────────────────────
def test_a_page_at_the_root_is_a_file_at_the_root():
assert export_paths([_doc("vlan-plan")]) == [("vlan-plan.md", "")]
def test_a_page_in_a_folder_lands_under_its_directory():
folder = _doc("network", kind="folder")
page = _doc("vlans", parent_id="network")
paths = dict(export_paths([folder, page]))
assert paths["network/vlans.md"] == ""
def test_a_folder_body_becomes_the_index_of_its_own_directory():
folder = _doc("network", kind="folder", body="# Network\n")
assert dict(export_paths([folder]))["network/index.md"] == "# Network\n"
def test_nesting_goes_as_deep_as_the_tree():
paths = dict(
export_paths(
[
_doc("a", kind="folder"),
_doc("b", kind="folder", parent_id="a"),
_doc("leaf", parent_id="b"),
]
)
)
assert "a/b/leaf.md" in paths
assert "a/b/index.md" in paths
@pytest.mark.parametrize(
"kind,directory",
[("device", "devices"), ("node", "nodes"), ("design", "designs")],
)
def test_linked_kinds_export_into_a_directory_named_for_the_kind(kind, directory):
assert export_paths([_doc("nas-01", kind=kind)]) == [(f"{directory}/nas-01.md", "")]
def test_a_linked_document_ignores_a_stray_parent():
"""Only page/folder are placed by `parent_id`; a device is placed by its link."""
paths = dict(export_paths([_doc("network", kind="folder"), _doc("nas", kind="device", parent_id="network")]))
assert "devices/nas.md" in paths
def test_the_body_is_written_through_untouched():
body = "---\ntags: [edge]\n---\n\n# OPNsense\n\nWAN on igb0.\n"
assert export_paths([_doc("opnsense", body=body)])[0][1] == body
def test_a_missing_parent_files_the_page_at_the_root():
"""A folder deleted out from under a page must not lose the page."""
assert export_paths([_doc("orphan", parent_id="gone")]) == [("orphan.md", "")]
def test_a_parent_cycle_terminates_instead_of_spinning():
docs = [
_doc("a", kind="folder", parent_id="b"),
_doc("b", kind="folder", parent_id="a"),
]
paths = [path for path, _ in export_paths(docs)]
assert len(paths) == 2
assert all(path.endswith("index.md") for path in paths)
# ── collisions and unsafe names ─────────────────────────────────────────────
def test_two_documents_wanting_one_path_both_survive():
"""Slugs are unique among siblings only, so a collision is reachable."""
paths = [path for path, _ in export_paths([_doc("a", title="Same", slug="same"), _doc("b", title="Same", slug="same")])]
assert sorted(paths) == ["same-2.md", "same.md"]
def test_a_collision_only_differing_by_case_is_still_a_collision():
"""Unzipping on macOS or Windows would otherwise overwrite one of them."""
paths = [path for path, _ in export_paths([_doc("a", slug="Same"), _doc("b", slug="same")])]
assert len(set(path.lower() for path in paths)) == 2
def test_a_collision_keeps_both_bodies():
entries = dict(export_paths([_doc("a", slug="same", body="first"), _doc("b", slug="same", body="second")]))
assert sorted(entries.values()) == ["first", "second"]
@pytest.mark.parametrize(
"raw,expected",
[
("../../etc/passwd", "etc-passwd"),
("..", "untitled"),
("/", "untitled"),
("", "untitled"),
(" ", "untitled"),
(".hidden", "hidden"),
("Salle des machines", "salle-des-machines"),
("a/b", "a-b"),
],
)
def test_a_segment_cannot_escape_the_archive(raw, expected):
assert safe_segment(raw) == expected
def test_an_unsafe_title_cannot_escape_the_archive():
path = export_paths([_doc("x", slug="../../../etc/passwd")])[0][0]
assert ".." not in path
assert not path.startswith("/")
# ── the archive ─────────────────────────────────────────────────────────────
def test_the_archive_reads_back_as_a_zip():
payload = build_zip([_doc("vlan-plan", body="# VLANs\n")])
assert _entries(payload) == {"vlan-plan.md": "# VLANs\n"}
def test_an_empty_documentation_space_is_an_empty_archive():
assert _entries(build_zip([])) == {}
# ── the route ───────────────────────────────────────────────────────────────
async def test_export_requires_auth(client: AsyncClient):
assert (await client.get("/api/v1/documents/export")).status_code == 401
async def test_export_is_not_read_as_a_document_id(client: AsyncClient, headers: dict):
"""`/export` is declared above `/{document_id}` — a 404 would mean it is not."""
res = await client.get("/api/v1/documents/export", headers=headers)
assert res.status_code == 200
assert res.headers["content-type"] == "application/zip"
async def test_export_names_the_download(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/documents/export", headers=headers)
assert res.headers["content-disposition"].startswith('attachment; filename="homelable-documentation-')
assert res.headers["content-disposition"].endswith('.zip"')
assert "Content-Disposition" in res.headers["access-control-expose-headers"]
async def test_export_mirrors_the_library_tree(client: AsyncClient, headers: dict):
folder = await _create(client, headers, title="Network", kind="folder")
await _create(client, headers, title="VLAN plan", parent_id=folder["id"])
res = await client.get("/api/v1/documents/export", headers=headers)
entries = _entries(res.content)
assert "network/vlan-plan.md" in entries
assert "network/index.md" in entries
async def test_export_carries_the_body_the_user_saved(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Runbook")
body = "---\ntags: [ops]\n---\n\n# Runbook\n\nPull the plug.\n"
res = await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": body}, headers=headers)
assert res.status_code == 200, res.text
entries = _entries((await client.get("/api/v1/documents/export", headers=headers)).content)
assert entries["runbook.md"] == body
async def test_export_files_a_device_document_under_devices(client: AsyncClient, headers: dict):
device = await _device(client, headers)
await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
entries = _entries((await client.get("/api/v1/documents/export", headers=headers)).content)
assert "devices/nas-01.md" in entries
assert entries["devices/nas-01.md"].strip()
async def test_export_includes_every_document(client: AsyncClient, headers: dict):
device = await _device(client, headers)
await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
folder = await _create(client, headers, title="Ops", kind="folder")
await _create(client, headers, title="Incident 42", parent_id=folder["id"])
await _create(client, headers, title="Loose page")
listed = (await client.get("/api/v1/documents", headers=headers)).json()
entries = _entries((await client.get("/api/v1/documents/export", headers=headers)).content)
assert len(entries) == len(listed)
async def test_export_of_an_empty_space_is_a_valid_empty_zip(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/documents/export", headers=headers)
assert res.status_code == 200
assert _entries(res.content) == {}
+151
View File
@@ -330,3 +330,154 @@ async def test_create_edge_child_node_abs_y_resolved_through_parent(client: Asyn
data = res.json()
assert data["source_handle"] == "bottom"
assert data["target_handle"] == "top-t"
# ---------------------------------------------------------------------------
# Connection-point guards (#435)
# ---------------------------------------------------------------------------
# An edge whose handle ID resolves to no element is dropped by React Flow: it
# persists in the DB and is simply invisible. Handles became settable over the
# MCP, so the endpoints now check them.
async def test_create_edge_rejects_a_handle_the_node_does_not_have(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post(
"/api/v1/edges",
json={"source": src, "target": tgt, "source_handle": "right-3", "target_handle": "top"},
headers=headers,
)
assert res.status_code == 422
assert "right-3" in res.json()["detail"]
assert (await client.get("/api/v1/edges", headers=headers)).json() == []
async def test_create_edge_rejects_a_side_the_node_opted_out_of(client: AsyncClient, headers: dict):
# left/right default to zero connection points, so even slot 0 is absent.
src = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1"}, headers=headers)).json()["id"]
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
res = await client.post(
"/api/v1/edges", json={"source": src, "target": tgt, "source_handle": "left"}, headers=headers
)
assert res.status_code == 422
async def test_create_edge_accepts_a_handle_the_node_opted_into(client: AsyncClient, headers: dict):
src = (
await client.post(
"/api/v1/nodes", json={"type": "proxmox", "label": "pve1", "right_handles": 1}, headers=headers
)
).json()["id"]
tgt = (
await client.post(
"/api/v1/nodes", json={"type": "proxmox", "label": "pve2", "left_handles": 1}, headers=headers
)
).json()["id"]
res = await client.post(
"/api/v1/edges",
json={"source": src, "target": tgt, "type": "cluster", "source_handle": "right", "target_handle": "left"},
headers=headers,
)
assert res.status_code == 201
assert res.json()["source_handle"] == "right"
async def test_create_edge_accepts_the_target_form_of_a_handle(client: AsyncClient, headers: dict):
# '<side>-t' is the invisible target twin the canvas renders beside slot 0;
# the auto-assignment stores exactly that, so it must validate.
src = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1"}, headers=headers)).json()["id"]
tgt = (
await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1", "top_handles": 2}, headers=headers)
).json()["id"]
res = await client.post(
"/api/v1/edges",
json={"source": src, "target": tgt, "source_handle": "bottom", "target_handle": "top-2-t"},
headers=headers,
)
assert res.status_code == 201
assert res.json()["target_handle"] == "top-2-t"
async def test_create_edge_leaves_a_foreign_handle_namespace_alone(client: AsyncClient, headers: dict, two_nodes):
# 'cluster-right' is not a per-side handle; no count here describes it, so
# the guard must not judge it.
src, tgt = two_nodes
res = await client.post(
"/api/v1/edges",
json={"source": src, "target": tgt, "type": "cluster", "source_handle": "cluster-right"},
headers=headers,
)
assert res.status_code == 201
async def test_update_edge_rejects_a_handle_the_node_does_not_have(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
edge_id = (
await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
).json()["id"]
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"source_handle": "bottom-9"}, headers=headers)
assert res.status_code == 422
edge = (await client.get("/api/v1/edges", headers=headers)).json()[0]
assert edge["source_handle"] == "bottom"
async def test_update_edge_accepts_a_handle_the_node_has(client: AsyncClient, headers: dict):
src = (
await client.post(
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "bottom_handles": 4}, headers=headers
)
).json()["id"]
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
edge_id = (
await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
).json()["id"]
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"source_handle": "bottom-4"}, headers=headers)
assert res.status_code == 200
assert res.json()["source_handle"] == "bottom-4"
async def test_auto_assignment_avoids_a_side_with_no_connection_point(client: AsyncClient, headers: dict):
# The caller supplied no handle, so the server corrects its own pick rather
# than raising: bottom is gone, and the node's remaining side is used.
src = (
await client.post(
"/api/v1/nodes",
json={"type": "switch", "label": "sw1", "bottom_handles": 0, "top_handles": 0, "right_handles": 2},
headers=headers,
)
).json()["id"]
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
assert res.status_code == 201
assert res.json()["source_handle"] == "right"
assert res.json()["target_handle"] == "top-t"
async def test_auto_assignment_keeps_its_pick_when_the_node_has_nothing(client: AsyncClient, headers: dict):
# Every side is empty: there is no better handle to reach for, and refusing
# the edge would lose more than it saves.
src = (
await client.post(
"/api/v1/nodes",
json={"type": "switch", "label": "sw1", "top_handles": 0, "bottom_handles": 0},
headers=headers,
)
).json()["id"]
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
assert res.status_code == 201
assert res.json()["source_handle"] == "bottom"
+84
View File
@@ -0,0 +1,84 @@
"""Unit tests for the per-side connection-point vocabulary.
Mirrors frontend/src/utils/handleUtils.ts — the two must agree, or the server
stores handles the canvas cannot draw.
"""
import pytest
from app.schemas.utils import (
MAX_HANDLES,
clamp_handles,
handle_count_field,
handle_id,
parse_side_handle,
removed_handle_ids,
side_default,
)
@pytest.mark.parametrize(("side", "expected"), [("top", 1), ("bottom", 1), ("left", 0), ("right", 0)])
def test_side_default(side: str, expected: int) -> None:
assert side_default(side) == expected
def test_handle_count_field() -> None:
assert handle_count_field("left") == "left_handles"
@pytest.mark.parametrize(("raw", "expected"), [(0, 0), (3, 3), (64, 64), (65, 64), (5000, MAX_HANDLES), (-1, 0)])
def test_clamp_handles_bounds(raw: int, expected: int) -> None:
assert clamp_handles("bottom", raw) == expected
@pytest.mark.parametrize("raw", [None, "4", float("nan"), float("inf"), True])
def test_clamp_handles_falls_back_to_the_side_default(raw: object) -> None:
# A missing or corrupt count still yields the historical number, per side.
assert clamp_handles("bottom", raw) == 1
assert clamp_handles("left", raw) == 0
def test_clamp_handles_truncates_a_fraction() -> None:
assert clamp_handles("bottom", 3.9) == 3
def test_handle_id_keeps_the_bare_side_name_for_slot_zero() -> None:
# Slot 0 stays 'bottom' so edges saved before the multi-handle expansion
# keep resolving.
assert handle_id("bottom", 0) == "bottom"
assert handle_id("bottom", 1) == "bottom-2"
assert handle_id("left", 3) == "left-4"
def test_removed_handle_ids() -> None:
assert removed_handle_ids("bottom", 4, 2) == {"bottom-3", "bottom-4"}
assert removed_handle_ids("bottom", 2, 2) == set()
assert removed_handle_ids("bottom", 1, 4) == set()
@pytest.mark.parametrize(
("handle", "expected"),
[
("top", ("top", 0)),
("bottom-2", ("bottom", 1)),
("left-4", ("left", 3)),
# The invisible target twin resolves to the same slot as its source.
("right-t", ("right", 0)),
("bottom-3-t", ("bottom", 2)),
],
)
def test_parse_side_handle(handle: str, expected: tuple[str, int]) -> None:
assert parse_side_handle(handle) == expected
@pytest.mark.parametrize("handle", ["cluster-right", "", "middle", "top-left", "bottom-x"])
def test_parse_side_handle_ignores_a_foreign_namespace(handle: str) -> None:
# Not a per-side handle: whoever owns it interprets it, not the side counts.
assert parse_side_handle(handle) is None
@pytest.mark.parametrize("handle", ["bottom-0", "bottom-1"])
def test_parse_side_handle_reports_an_impossible_slot(handle: str) -> None:
# The numbered form is 2-based, so these are IDs the canvas never emits;
# slot -1 means no count can satisfy them.
assert parse_side_handle(handle) == ("bottom", -1)
+181
View File
@@ -487,3 +487,184 @@ async def test_a_device_node_never_keeps_a_description(client: AsyncClient, head
)
assert res.json()["description"] is None
assert res.json()["notes"] == "Backs up nightly."
# ---------------------------------------------------------------------------
# Connection points (#435)
# ---------------------------------------------------------------------------
# Handle counts became writable over the MCP, which reaches these fields without
# the canvas UI's own bounds and edge bookkeeping. Both now live server-side.
async def test_handle_count_is_clamped_on_create(client: AsyncClient, headers: dict):
# The renderer clamps to 0..64 anyway (handleUtils.clampHandles), so an
# unclamped row would draw a different node than it stores.
res = await client.post(
"/api/v1/nodes",
json={"type": "switch", "label": "sw1", "bottom_handles": 5000, "left_handles": -3},
headers=headers,
)
assert res.status_code == 201
assert res.json()["bottom_handles"] == 64
assert res.json()["left_handles"] == 0
async def test_handle_count_is_clamped_on_update(client: AsyncClient, headers: dict):
node_id = (
await client.post("/api/v1/nodes", json={"type": "switch", "label": "sw1"}, headers=headers)
).json()["id"]
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"right_handles": 999}, headers=headers)
assert res.status_code == 200
assert res.json()["right_handles"] == 64
async def test_a_handle_count_of_zero_is_honoured(client: AsyncClient, headers: dict):
# 0 is a real value, not a missing one: a side can have no connection point.
node_id = (
await client.post("/api/v1/nodes", json={"type": "switch", "label": "sw1"}, headers=headers)
).json()["id"]
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"top_handles": 0}, headers=headers)
assert res.json()["top_handles"] == 0
async def _edge_by_id(client: AsyncClient, headers: dict, edge_id: str) -> dict:
edges = (await client.get("/api/v1/edges", headers=headers)).json()
return next(e for e in edges if e["id"] == edge_id)
async def test_shrinking_a_side_moves_its_edges_to_slot_zero(client: AsyncClient, headers: dict):
# Regression: React Flow silently drops an edge whose handle no longer
# exists, so the link vanishes from the canvas with no error.
src = (
await client.post(
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "bottom_handles": 4}, headers=headers
)
).json()["id"]
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "nas1"}, headers=headers)).json()["id"]
edge_id = (
await client.post(
"/api/v1/edges",
json={"source": src, "target": tgt, "source_handle": "bottom-4", "target_handle": "top"},
headers=headers,
)
).json()["id"]
await client.patch(f"/api/v1/nodes/{src}", json={"bottom_handles": 2}, headers=headers)
edge = await _edge_by_id(client, headers, edge_id)
assert edge["source_handle"] == "bottom"
assert edge["target_handle"] == "top"
async def test_shrinking_a_side_leaves_the_surviving_handles_alone(client: AsyncClient, headers: dict):
src = (
await client.post(
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "bottom_handles": 4}, headers=headers
)
).json()["id"]
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "nas1"}, headers=headers)).json()["id"]
edge_id = (
await client.post(
"/api/v1/edges",
json={"source": src, "target": tgt, "source_handle": "bottom-2", "target_handle": "top"},
headers=headers,
)
).json()["id"]
await client.patch(f"/api/v1/nodes/{src}", json={"bottom_handles": 2}, headers=headers)
assert (await _edge_by_id(client, headers, edge_id))["source_handle"] == "bottom-2"
async def test_a_side_dropped_to_zero_falls_back_to_bottom(client: AsyncClient, headers: dict):
# Slot 0 goes away with the side, so there is nothing on it to fall back to.
src = (
await client.post(
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "right_handles": 2}, headers=headers
)
).json()["id"]
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "nas1"}, headers=headers)).json()["id"]
edge_id = (
await client.post(
"/api/v1/edges",
json={"source": src, "target": tgt, "source_handle": "right-2", "target_handle": "top"},
headers=headers,
)
).json()["id"]
await client.patch(f"/api/v1/nodes/{src}", json={"right_handles": 0}, headers=headers)
assert (await _edge_by_id(client, headers, edge_id))["source_handle"] == "bottom"
async def test_the_target_end_is_remapped_too(client: AsyncClient, headers: dict):
src = (await client.post("/api/v1/nodes", json={"type": "router", "label": "r1"}, headers=headers)).json()["id"]
tgt = (
await client.post(
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "top_handles": 3}, headers=headers
)
).json()["id"]
edge_id = (
await client.post(
"/api/v1/edges",
json={"source": src, "target": tgt, "source_handle": "bottom", "target_handle": "top-3"},
headers=headers,
)
).json()["id"]
await client.patch(f"/api/v1/nodes/{tgt}", json={"top_handles": 1}, headers=headers)
edge = await _edge_by_id(client, headers, edge_id)
assert edge["target_handle"] == "top"
assert edge["source_handle"] == "bottom"
async def test_growing_a_side_touches_no_edge(client: AsyncClient, headers: dict):
src = (
await client.post(
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "bottom_handles": 2}, headers=headers
)
).json()["id"]
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "nas1"}, headers=headers)).json()["id"]
edge_id = (
await client.post(
"/api/v1/edges",
json={"source": src, "target": tgt, "source_handle": "bottom-2", "target_handle": "top"},
headers=headers,
)
).json()["id"]
await client.patch(f"/api/v1/nodes/{src}", json={"bottom_handles": 8}, headers=headers)
assert (await _edge_by_id(client, headers, edge_id))["source_handle"] == "bottom-2"
async def test_another_nodes_edges_are_not_remapped(client: AsyncClient, headers: dict):
# The shrink is scoped to the node being edited; an unrelated link that
# happens to sit on the same handle ID must survive untouched.
shrunk = (
await client.post(
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "bottom_handles": 4}, headers=headers
)
).json()["id"]
other = (
await client.post(
"/api/v1/nodes", json={"type": "switch", "label": "sw2", "bottom_handles": 4}, headers=headers
)
).json()["id"]
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "nas1"}, headers=headers)).json()["id"]
edge_id = (
await client.post(
"/api/v1/edges",
json={"source": other, "target": tgt, "source_handle": "bottom-4", "target_handle": "top"},
headers=headers,
)
).json()["id"]
await client.patch(f"/api/v1/nodes/{shrunk}", json={"bottom_handles": 1}, headers=headers)
assert (await _edge_by_id(client, headers, edge_id))["source_handle"] == "bottom-4"
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "3.4.0",
"version": "3.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "3.4.0",
"version": "3.4.1",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
@@ -7194,9 +7194,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "3.4.0",
"version": "3.4.1",
"type": "module",
"scripts": {
"dev": "vite",
+5 -14
View File
@@ -6,8 +6,6 @@ import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, m
import { generateUUID } from '@/utils/uuid'
import { getCenteredPosition } from '@/utils/viewportCenter'
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { copyToClipboard } from '@/utils/clipboard'
import { getDesignIdFromUrl, setDesignIdInUrl } from '@/utils/designUrl'
import { useDocsUrlSync } from '@/hooks/useDocsUrlSync'
import { withBase } from '@/utils/basePath'
@@ -383,6 +381,9 @@ export default function App() {
setEditNodeId(null)
setThemeModalOpen(false)
setZigbeeImportOpen(false)
// The Documentation steps replace the whole main pane; every other step
// anchors on the toolbar or the canvas, so put the view back first.
setAppView('canvas')
// Clear any tour-driven multi-selection so the DetailPanel closes.
useCanvasStore.setState((s) => ({
nodes: s.nodes.map((n) => (n.selected ? { ...n, selected: false } : n)),
@@ -418,7 +419,8 @@ export default function App() {
},
openStyle: () => setThemeModalOpen(true),
openZigbeeImport: () => setZigbeeImportOpen(true),
}), [openInventoryModal])
openDocumentation: () => setAppView('documentation'),
}), [openInventoryModal, setAppView])
// Load designs + canvas on auth (or immediately in standalone mode, which has
// no auth gate).
@@ -790,16 +792,6 @@ export default function App() {
toast.success('Canvas auto-arranged')
}, [nodes, edges, applyLayout])
const handleExportMd = useCallback(async () => {
const md = generateMarkdownTable(nodes)
if (!md) { toast.error('No nodes to export'); return }
if (await copyToClipboard(md)) {
toast.success('Markdown table copied to clipboard')
} else {
toast.error('Markdown copy failed')
}
}, [nodes])
const handleExportYaml = useCallback(() => {
if (nodes.length === 0) { toast.error('No nodes to export'); return }
const content = exportCanvasToYaml(nodes, edges)
@@ -1183,7 +1175,6 @@ export default function App() {
onUndo={undo}
onRedo={redo}
onShortcuts={() => setShortcutsOpen(true)}
onExportMd={handleExportMd}
onExportYaml={handleExportYaml}
onImportYaml={handleImportYaml}
onViewOnly={handleViewOnly}
+11
View File
@@ -212,6 +212,14 @@ export const scanApi = {
skipped: number
skipped_devices: SkippedDevice[]
}>('/scan/pending/bulk-approve', { device_ids: ids, design_id: designId ?? undefined }),
/**
* Fold several inventory rows into one, keeping `winnerId`. Returns the
* survivor. The losers' facts, canvas nodes, rack mounts and documents move
* onto it first — the identity match only runs when a row is created, so two
* rows for one host never converge on their own.
*/
merge: (winnerId: string, loserIds: string[]) =>
api.post<InventoryEntry>('/scan/pending/merge', { winner_id: winnerId, loser_ids: loserIds }),
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
@@ -350,6 +358,9 @@ export const documentsApi = {
params: { block, device_id: deviceId },
}),
coverage: () => api.get<import('@/documentation/types').DocCoverage>('/documents/coverage'),
// A zip of `.md` files mirroring the tree. Fetched through axios rather than
// a plain link so the Bearer header rides along.
export: () => api.get<Blob>('/documents/export', { responseType: 'blob' }),
scaffold: (data: { device_ids?: string[]; only_with_notes?: boolean }) =>
api.post<{ created: import('@/documentation/types').DocumentSummary[]; skipped: number }>(
'/documents/scaffold',
@@ -22,6 +22,7 @@ import { getCenteredPosition } from '@/utils/viewportCenter'
import { sourceBuckets, orderedSources, isRackDevice, SOURCE_META, type SourceBucket } from '@/utils/deviceSources'
import { isRackable } from '@/utils/rackable'
import { ProxmoxApproveModal, type ProxmoxApproveChoice } from '@/components/modals/ProxmoxApproveModal'
import { MergeDevicesModal } from '@/components/modals/MergeDevicesModal'
import { layoutProxmoxContainers, measureProxmoxContainers } from '@/utils/proxmoxContainerLayout'
interface DeviceInventoryModalProps {
@@ -184,6 +185,9 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
// Set when a Proxmox host with guests is about to be placed — the user picks
// whether the guests come along, and nested or linked.
const [proxmoxPrompt, setProxmoxPrompt] = useState<{ host: InventoryEntry; children: InventoryEntry[] } | null>(null)
// Set when the user asks to merge the selected rows — they pick the survivor.
const [mergePrompt, setMergePrompt] = useState<InventoryEntry[] | null>(null)
const [merging, setMerging] = useState(false)
const load = useCallback(async () => {
// Tour/demo mode: show injected devices, never touch the backend.
@@ -589,6 +593,27 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
}
}
const handleMerge = async (winnerId: string) => {
const ids = (mergePrompt ?? []).map((d) => d.id).filter((id) => id !== winnerId)
if (ids.length === 0) return
setMerging(true)
try {
await scanApi.merge(winnerId, ids)
setMergePrompt(null)
setSelectedIds(new Set())
// Reload rather than patch: a merge moves canvas links around, so
// `canvas_count` on the survivor is only right coming from the server.
await load()
toast.success(`Merged ${ids.length + 1} devices into one`, {
description: 'Facts, canvas nodes, rack mounts and documents were kept.',
})
} catch {
toast.error('Failed to merge devices')
} finally {
setMerging(false)
}
}
const handleBulkHide = async () => {
const ids = [...selectedIds]
if (ids.length === 0) return
@@ -851,6 +876,14 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
Clear
</button>
<div className="flex-1" />
<button
onClick={() => setMergePrompt(devices.filter((d) => selectedIds.has(d.id)))}
disabled={selectedIds.size < 2}
title="Fold the selected rows into one device"
className="text-xs px-3 py-1.5 rounded bg-[#00d4ff]/20 text-[#00d4ff] hover:bg-[#00d4ff]/30 disabled:opacity-40 font-medium transition-colors"
>
Merge ({selectedIds.size})
</button>
{statusFilter === 'pending' && (
<>
<button
@@ -957,6 +990,14 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
}}
/>
<MergeDevicesModal
open={mergePrompt !== null}
devices={mergePrompt ?? []}
busy={merging}
onCancel={() => setMergePrompt(null)}
onConfirm={handleMerge}
/>
<ProxmoxApproveModal
open={proxmoxPrompt !== null}
host={proxmoxPrompt?.host ?? null}
@@ -0,0 +1,123 @@
import { useState } from 'react'
import { Merge, Layers } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import type { InventoryEntry } from '@/types'
import { deviceName, factCount, suggestWinner } from '@/utils/mergeWinner'
const ACCENT = '#00d4ff'
interface MergeDevicesModalProps {
open: boolean
/** The rows the user selected in the inventory. Two or more. */
devices: InventoryEntry[]
onCancel: () => void
onConfirm: (winnerId: string) => void
/** True while the request is in flight — the dialog stays up, disabled. */
busy?: boolean
}
/**
* Asked before several Device Inventory rows are folded into one.
*
* The inventory only matches devices when a row is created, so two rows for one
* host — minted before either carried the address that would have matched them
* — never converge on their own. This is where the user says they are the same
* machine and picks which row survives.
*
* Nothing is thrown away by the merge itself: the survivor keeps every fact it
* has and fills its gaps from the others, and canvas nodes, rack mounts and
* documents are re-pointed at it. The one thing the user must choose is which
* id (and so which of two *conflicting* values) stands.
*/
export function MergeDevicesModal({ open, devices, onCancel, onConfirm, busy = false }: MergeDevicesModalProps) {
// Only the user's override is state. The winner is otherwise derived, so a
// choice held over from a previous selection — a row that no longer exists —
// falls back to the suggestion instead of pointing at nothing.
const [picked, setPicked] = useState<string | undefined>(undefined)
if (devices.length < 2) return null
const winnerId = devices.some((d) => d.id === picked) ? picked : suggestWinner(devices)
const winner = devices.find((d) => d.id === winnerId)
return (
<Dialog open={open} onOpenChange={(v) => !v && onCancel()}>
<DialogContent className="bg-[#161b22] border-border max-w-2xl">
<DialogHeader>
<DialogTitle className="text-foreground flex items-center gap-2">
<Merge size={16} style={{ color: ACCENT }} />
Merge {devices.length} devices into one
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-1">
<p className="text-xs text-muted-foreground">
Keep which row? The others fold into it — their facts fill its gaps, and every
canvas node, rack mount and document they own moves across.
</p>
<div className="max-h-[50vh] overflow-y-auto space-y-1.5">
{devices.map((d) => (
<label
key={d.id}
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-xs cursor-pointer transition-colors ${
d.id === winnerId
? 'border-[#00d4ff]/50 bg-[#00d4ff]/10'
: 'border-border bg-[#0d1117]/60 hover:border-muted-foreground/40'
}`}
>
<input
type="radio"
name="merge-winner"
checked={d.id === winnerId}
onChange={() => setPicked(d.id)}
className="mt-0.5 cursor-pointer shrink-0"
style={{ accentColor: ACCENT }}
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="text-foreground font-medium truncate">{deviceName(d)}</span>
{(d.canvas_count ?? 0) > 0 && (
<span className="flex items-center gap-1 text-[10px] text-[#00d4ff] shrink-0">
<Layers size={10} />
{d.canvas_count}
</span>
)}
</span>
<span className="block text-[11px] text-muted-foreground font-mono break-all">
{[d.ip, d.mac, d.ieee_address].filter(Boolean).join(' · ') || 'no address'}
</span>
<span className="block text-[11px] text-muted-foreground">
{factCount(d)} fact{factCount(d) !== 1 ? 's' : ''}
{d.services?.length ? ` · ${d.services.length} service${d.services.length !== 1 ? 's' : ''}` : ''}
</span>
</span>
</label>
))}
</div>
<p className="text-[11px] text-muted-foreground">
{winner
? `Merging cannot be undone. ${devices.length - 1} row${devices.length - 1 !== 1 ? 's' : ''} will be deleted once their facts and links are on ${deviceName(winner)}.`
: 'Pick the row to keep.'}
</p>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
Cancel
</Button>
<Button
size="sm"
onClick={() => winnerId && onConfirm(winnerId)}
disabled={!winnerId || busy}
style={{ backgroundColor: `${ACCENT}33`, color: ACCENT }}
>
{busy ? 'Merging…' : `Merge into ${winner ? deviceName(winner) : 'this row'}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -16,6 +16,7 @@ const mockHidden = vi.fn()
const mockAddNode = vi.fn()
const mockSetSelectedNode = vi.fn()
const mockProxmoxChildren = vi.fn()
const mockMerge = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: {
@@ -30,6 +31,7 @@ vi.mock('@/api/client', () => ({
restore: (...a: unknown[]) => mockRestore(...a),
bulkRestore: (...a: unknown[]) => mockBulkRestore(...a),
proxmoxChildren: (...a: unknown[]) => mockProxmoxChildren(...a),
merge: (...a: unknown[]) => mockMerge(...a),
},
}))
@@ -497,6 +499,29 @@ describe('DeviceInventoryModal', () => {
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a']))
})
it('merge needs two rows, then folds them into the chosen survivor', async () => {
mockMerge.mockResolvedValue({ data: { ...DEVICE_IP } })
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
// One row is not a duplicate of anything — the action stays disabled.
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
expect(screen.getByRole('button', { name: /Merge \(1\)/ })).toBeDisabled()
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
fireEvent.click(screen.getByRole('button', { name: /Merge \(2\)/ }))
// The dialog asks which row survives before anything is sent.
expect(mockMerge).not.toHaveBeenCalled()
fireEvent.click(await screen.findByRole('button', { name: /^merge into/i }))
await waitFor(() => expect(mockMerge).toHaveBeenCalledTimes(1))
const [winner, losers] = mockMerge.mock.calls[0]
expect([winner, ...losers].sort()).toEqual(['dev-a', 'dev-b'])
// Reloaded, so the survivor's canvas count comes from the server.
await waitFor(() => expect(mockPending).toHaveBeenCalledTimes(2))
})
it('does not load when closed', () => {
render(<DeviceInventoryModal {...baseProps} open={false} />)
expect(mockPending).not.toHaveBeenCalled()
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { MergeDevicesModal } from '../MergeDevicesModal'
import type { InventoryEntry } from '@/types'
const devices = [
{
id: 'proxmox', label: 'n8n', ip: '192.168.1.62', mac: 'bc:24:11:95:af:8c',
ieee_address: 'pve-proxmox-ai-130', status: 'pending', services: [],
discovered_at: '2026-04-01T00:00:00Z',
},
{
id: 'canvas', label: 'n8n', ip: '192.168.1.62', mac: 'bc:24:11:95:af:8c',
status: 'approved', canvas_count: 1, services: [],
discovered_at: '2026-08-01T00:00:00Z',
},
{
id: 'stub', label: 'n8n', ip: null, mac: null, status: 'pending', services: [],
discovered_at: '2026-08-10T00:00:00Z',
},
] as unknown as InventoryEntry[]
const props = {
open: true,
devices,
onCancel: vi.fn(),
onConfirm: vi.fn(),
}
describe('MergeDevicesModal', () => {
beforeEach(() => {
props.onCancel.mockReset()
props.onConfirm.mockReset()
})
it('renders nothing for a selection that cannot be merged', () => {
const { container } = render(<MergeDevicesModal {...props} devices={[devices[0]]} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('lists every selected row with its addresses', () => {
render(<MergeDevicesModal {...props} />)
expect(screen.getByText(/merge 3 devices into one/i)).toBeDefined()
expect(screen.getAllByRole('radio')).toHaveLength(3)
expect(screen.getByText(/pve-proxmox-ai-130/)).toBeDefined()
// The row with no address says so rather than rendering an empty line.
expect(screen.getByText('no address')).toBeDefined()
})
it('preselects the suggested survivor and merges into it', () => {
render(<MergeDevicesModal {...props} />)
const radios = screen.getAllByRole('radio') as HTMLInputElement[]
expect(radios[0].checked).toBe(true) // the IEEE-bearing row
fireEvent.click(screen.getByRole('button', { name: /^merge into/i }))
expect(props.onConfirm).toHaveBeenCalledWith('proxmox')
})
it('lets the user keep a different row instead', () => {
render(<MergeDevicesModal {...props} />)
fireEvent.click(screen.getAllByRole('radio')[1])
fireEvent.click(screen.getByRole('button', { name: /^merge into/i }))
expect(props.onConfirm).toHaveBeenCalledWith('canvas')
})
it('warns how many rows go, and that it cannot be undone', () => {
render(<MergeDevicesModal {...props} />)
expect(screen.getByText(/2 rows will be deleted/i)).toBeDefined()
expect(screen.getByText(/cannot be undone/i)).toBeDefined()
})
it('disables both actions while the merge is in flight', () => {
render(<MergeDevicesModal {...props} busy />)
expect(screen.getByRole('button', { name: /merging/i })).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: /cancel/i })).toHaveProperty('disabled', true)
expect(props.onConfirm).not.toHaveBeenCalled()
})
it('cancels without merging', () => {
render(<MergeDevicesModal {...props} />)
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(props.onCancel).toHaveBeenCalled()
expect(props.onConfirm).not.toHaveBeenCalled()
})
})
@@ -251,6 +251,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
collapsed={collapsed}
active={view === 'documentation'}
onClick={() => setView('documentation')}
dataTour="documentation"
/>
)}
{/* The hidden devices are a filter of this same modal, reached from
+2 -8
View File
@@ -1,5 +1,5 @@
import { useRef } from 'react'
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload, Eye } from 'lucide-react'
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, FileDown, Upload, Eye } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/Logo'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -17,13 +17,12 @@ interface ToolbarProps {
onUndo: () => void
onRedo: () => void
onShortcuts: () => void
onExportMd: () => void
onExportYaml: () => void
onImportYaml: (content: string) => void
onViewOnly: () => void
}
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml, onViewOnly }: ToolbarProps) {
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportYaml, onImportYaml, onViewOnly }: ToolbarProps) {
const { hasUnsavedChanges: canvasDirty, past, future } = useCanvasStore()
const isRack = useDesignStore((s) => s.activeDesignType) === 'rack'
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
@@ -98,11 +97,6 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExport} title="Download canvas as PNG">
<FileDown size={14} /> PNG
</Button>
{!isRack && (
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
<Table2 size={14} /> MD
</Button>
)}
{/* Live view reads backend/localStorage canvas; pointless in standalone
where the editor already shows the only (localStorage) copy. Rack
canvases have no live view yet. */}
@@ -306,6 +306,12 @@ describe('Sidebar', () => {
// stops carrying leaves the step spotlighting nothing.
expect(document.querySelector(step!.anchor!)).toBeInTheDocument()
// The Documentation entry is the first Documentation step's target, and the
// only way the tour has into the section.
const docs = STEPS.find((s) => s.id === 'docs')
expect(docs?.anchor).toBe('[data-tour="documentation"]')
expect(document.querySelector(docs!.anchor!)).toBeInTheDocument()
// Unmount before restoring, so the store reset never re-renders the sidebar.
unmount()
useDesignStore.setState({
@@ -28,7 +28,6 @@ const defaultProps = {
onUndo: vi.fn(),
onRedo: vi.fn(),
onShortcuts: vi.fn(),
onExportMd: vi.fn(),
onExportYaml: vi.fn(),
onImportYaml: vi.fn(),
onViewOnly: vi.fn(),
@@ -116,6 +115,6 @@ describe('Toolbar (standalone)', () => {
expect(screen.queryByText('View')).not.toBeInTheDocument()
// Other actions remain.
expect(screen.getByText('Save')).toBeInTheDocument()
expect(screen.getByText('MD')).toBeInTheDocument()
expect(screen.getByText('PNG')).toBeInTheDocument()
})
})
@@ -33,6 +33,7 @@ const noop = {
onToggleStar: vi.fn(),
onMarkReviewed: vi.fn(),
onRegenerate: vi.fn(),
onDownload: vi.fn(),
onDelete: vi.fn(),
onOpenDoc: vi.fn(),
onCreateFromLink: vi.fn(),
@@ -92,3 +93,24 @@ describe('DocViewer — tags', () => {
expect(screen.getByText('Tag')).toBeInTheDocument()
})
})
describe('DocViewer — download', () => {
it('hands the open document to the host on click', () => {
const onDownload = vi.fn()
render(<DocViewer {...noop} doc={makeDoc()} onSetTags={vi.fn()} onDownload={onDownload} />)
fireEvent.click(screen.getByLabelText('Download this document'))
expect(onDownload).toHaveBeenCalledTimes(1)
})
// A folder hides Regenerate, and the two buttons sit side by side — the
// download must not be hidden along with it.
it('is offered for a folder too', () => {
const onDownload = vi.fn()
render(
<DocViewer {...noop} doc={makeDoc({ kind: 'folder' })} onSetTags={vi.fn()} onDownload={onDownload} />,
)
expect(screen.queryByLabelText('Regenerate this document')).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Download this document'))
expect(onDownload).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,128 @@
/**
* The Export all button in the Documentation layout.
*
* It is the only control in the view that acts on the whole space rather than
* the open document, so it lives in the tree footer and has to stay usable with
* no document selected — and unusable when there is nothing to export.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { documentsApi } from '@/api/client'
import { DocumentationView } from '../components/DocumentationView'
import { useDocsStore } from '../store'
import type { DocumentSummary } from '../types'
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/api/client', () => ({
documentsApi: {
list: vi.fn().mockResolvedValue({ data: [] }),
coverage: vi.fn().mockResolvedValue({
data: { devices: 0, documented: 0, header_only: 0, notes_unmigrated: 0 },
}),
export: vi.fn(),
},
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
}))
const exportAll = vi.mocked(documentsApi.export)
const listDocs = vi.mocked(documentsApi.list)
function summary(overrides: Partial<DocumentSummary> = {}): DocumentSummary {
return {
id: 'doc-1',
kind: 'page',
title: 'VLAN plan',
slug: 'vlan-plan',
sort_order: 0,
tags: [],
frontmatter: {},
starred: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
} as DocumentSummary
}
let clicks = 0
let savedName: string | null = null
beforeEach(() => {
clicks = 0
savedName = null
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:zip')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clicks += 1
savedName = this.download
})
useDocsStore.setState({ docs: [], loaded: false, openDoc: null, filter: '' })
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.clearAllMocks()
})
/** The view loads the tree itself on mount, so the docs come from the server. */
async function renderView(docs: DocumentSummary[]) {
listDocs.mockResolvedValue({ data: docs } as never)
render(<DocumentationView />)
await waitFor(() => expect(useDocsStore.getState().loaded).toBe(true))
return screen.getByText(/Export all|Exporting/).closest('button') as HTMLButtonElement
}
describe('Export all', () => {
it('is offered with no document open', async () => {
const button = await renderView([summary()])
expect(button).toBeEnabled()
expect(useDocsStore.getState().openDoc).toBeNull()
})
it('is disabled while there is nothing written down', async () => {
expect(await renderView([])).toBeDisabled()
})
it('saves the archive the server returns', async () => {
exportAll.mockResolvedValue({
data: new Blob(['PK']),
headers: { 'content-disposition': 'attachment; filename="homelable-documentation-20260909.zip"' },
} as never)
fireEvent.click(await renderView([summary()]))
await waitFor(() => expect(clicks).toBe(1))
expect(savedName).toBe('homelable-documentation-20260909.zip')
})
// The request is a server round trip on every document body — a second click
// mid-flight would download the same archive twice.
it('cannot be fired again while it is running', async () => {
let release: (value: unknown) => void = () => {}
exportAll.mockReturnValue(new Promise((resolve) => {
release = resolve
}) as never)
const button = await renderView([summary()])
fireEvent.click(button)
await waitFor(() => expect(button).toBeDisabled())
expect(screen.getByText('Exporting…')).toBeInTheDocument()
release({ data: new Blob(['PK']), headers: {} })
await waitFor(() => expect(exportAll).toHaveBeenCalledTimes(1))
})
it('says so and stays usable when the export fails', async () => {
exportAll.mockRejectedValue(new Error('500'))
const { toast } = await import('sonner')
const button = await renderView([summary()])
fireEvent.click(button)
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Could not export the documentation'))
expect(clicks).toBe(0)
await waitFor(() => expect(button).toBeEnabled())
})
})
@@ -0,0 +1,41 @@
/**
* The Documentation tour steps ring elements of this view by selector. Nothing
* else reads those attributes, so only a test keeps a rename from quietly
* leaving two steps spotlighting an empty screen.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { STEPS } from '@/walkthrough/steps'
import { DocumentationView } from '../components/DocumentationView'
import { useDocsStore } from '../store'
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/api/client', () => ({
documentsApi: {
list: vi.fn().mockResolvedValue({ data: [] }),
coverage: vi.fn().mockResolvedValue({
data: { devices: 0, documented: 0, header_only: 0, notes_unmigrated: 0 },
}),
},
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
}))
beforeEach(() => {
useDocsStore.setState({ docs: [], loaded: false, openDoc: null, filter: '' })
})
afterEach(() => cleanup())
describe('Documentation tour anchors', () => {
it('carries the anchors the docs-write and docs-devices steps spotlight', async () => {
render(<DocumentationView />)
await waitFor(() => expect(screen.getByLabelText('New document')).toBeInTheDocument())
for (const id of ['docs-write', 'docs-devices']) {
const step = STEPS.find((s) => s.id === id)
expect(step?.anchor).toBeDefined()
expect(document.querySelector(step!.anchor!)).toBeInTheDocument()
}
})
})
@@ -0,0 +1,175 @@
/**
* Downloading documentation.
*
* A page is written from the body the viewer already holds; the whole space
* comes back from the server as a zip. Both end at the same anchor click, so
* that is what these assert on.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { documentsApi } from '@/api/client'
import {
docFilename,
downloadAllDocs,
downloadBlob,
downloadDoc,
filenameFromDisposition,
} from '../export'
import type { Doc } from '../types'
vi.mock('@/api/client', () => ({ documentsApi: { export: vi.fn() } }))
const exportAll = vi.mocked(documentsApi.export)
function makeDoc(overrides: Partial<Doc> = {}): Doc {
return {
id: 'doc-1',
kind: 'page',
title: 'VLAN plan',
slug: 'vlan-plan',
sort_order: 0,
tags: [],
frontmatter: {},
starred: false,
body: '---\ntags: [net]\n---\n\n# VLAN plan\n',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
} as Doc
}
/** What the browser was handed: the anchor's name and the blob behind it. */
let saved: { name: string; blob: Blob } | null = null
let revoked: string[] = []
let clicks = 0
beforeEach(() => {
saved = null
revoked = []
clicks = 0
let blob: Blob | null = null
vi.spyOn(URL, 'createObjectURL').mockImplementation((value: Blob | MediaSource) => {
blob = value as Blob
return 'blob:doc'
})
vi.spyOn(URL, 'revokeObjectURL').mockImplementation((url: string) => {
revoked.push(url)
})
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clicks += 1
saved = { name: this.download, blob: blob as Blob }
})
})
afterEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
})
// ── file names ──────────────────────────────────────────────────────────────
describe('docFilename', () => {
it('is the slug with a markdown extension', () => {
expect(docFilename(makeDoc())).toBe('vlan-plan.md')
})
it('falls back on the title when there is no slug', () => {
expect(docFilename({ slug: '', title: 'Salle des machines' })).toBe('salle-des-machines.md')
})
it('cannot be turned into a path by a hand-edited title', () => {
const name = docFilename({ slug: '../../etc/passwd', title: 'x' })
expect(name).not.toContain('/')
expect(name).not.toContain('..')
})
it('names an untitled document rather than producing a bare extension', () => {
expect(docFilename({ slug: '', title: ' ' })).toBe('untitled.md')
})
})
describe('filenameFromDisposition', () => {
it('reads the quoted name the server sent', () => {
expect(
filenameFromDisposition('attachment; filename="homelable-documentation-20260909.zip"', 'x.zip'),
).toBe('homelable-documentation-20260909.zip')
})
it('reads an unquoted name', () => {
expect(filenameFromDisposition('attachment; filename=docs.zip', 'x.zip')).toBe('docs.zip')
})
it('falls back when the header is missing', () => {
expect(filenameFromDisposition(undefined, 'x.zip')).toBe('x.zip')
})
it('falls back when the header carries no filename', () => {
expect(filenameFromDisposition('attachment', 'x.zip')).toBe('x.zip')
})
})
// ── writing the file ────────────────────────────────────────────────────────
describe('downloadBlob', () => {
it('clicks an anchor and releases the object URL', () => {
downloadBlob(new Blob(['x']), 'a.md')
expect(clicks).toBe(1)
expect(saved?.name).toBe('a.md')
expect(revoked).toEqual(['blob:doc'])
})
it('leaves no anchor behind in the document', () => {
downloadBlob(new Blob(['x']), 'a.md')
expect(document.querySelectorAll('a').length).toBe(0)
})
})
describe('downloadDoc', () => {
it('writes the body verbatim, frontmatter included', async () => {
const doc = makeDoc()
downloadDoc(doc)
expect(await saved!.blob.text()).toBe(doc.body)
})
it('writes it as markdown under the document name', () => {
downloadDoc(makeDoc())
expect(saved?.name).toBe('vlan-plan.md')
expect(saved?.blob.type).toContain('text/markdown')
})
it('writes an empty file rather than throwing on a body-less document', async () => {
downloadDoc({ slug: 'empty', title: 'Empty', body: '' })
expect(await saved!.blob.text()).toBe('')
})
it('asks the server for nothing — the body is already here', () => {
downloadDoc(makeDoc())
expect(exportAll).not.toHaveBeenCalled()
})
})
// ── the whole space ─────────────────────────────────────────────────────────
describe('downloadAllDocs', () => {
it('saves the archive under the name the server put on it', async () => {
exportAll.mockResolvedValue({
data: new Blob(['PK']),
headers: { 'content-disposition': 'attachment; filename="homelable-documentation-20260909.zip"' },
} as never)
await downloadAllDocs()
expect(saved?.name).toBe('homelable-documentation-20260909.zip')
expect(saved?.blob.type).toBe('application/zip')
})
it('falls back on a plain name when the server sent no disposition', async () => {
exportAll.mockResolvedValue({ data: new Blob(['PK']), headers: {} } as never)
await downloadAllDocs()
expect(saved?.name).toBe('homelable-documentation.zip')
})
it('rejects rather than saving an empty file when the request fails', async () => {
exportAll.mockRejectedValue(new Error('500'))
await expect(downloadAllDocs()).rejects.toThrow()
expect(clicks).toBe(0)
})
})
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { Clock, History, Link2, Pencil, Plus, RefreshCw, Star, Trash2, X } from 'lucide-react'
import { Clock, Download, History, Link2, Pencil, Plus, RefreshCw, Star, Trash2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@@ -39,6 +39,8 @@ interface Props {
onToggleStar: () => void
onMarkReviewed: () => void
onRegenerate: () => void
/** Saves this document to disk as the `.md` file its body already is. */
onDownload: () => void
onDelete: () => void
onOpenDoc: (id: string) => void
onCreateFromLink: (label: string) => void
@@ -77,6 +79,7 @@ export function DocViewer({
onToggleStar,
onMarkReviewed,
onRegenerate,
onDownload,
onDelete,
onOpenDoc,
onCreateFromLink,
@@ -178,6 +181,16 @@ export function DocViewer({
<RefreshCw />
</Button>
)}
<Button
size="icon-xs"
variant="ghost"
title="Download this document as Markdown"
aria-label="Download this document"
onClick={onDownload}
className="cursor-pointer"
>
<Download />
</Button>
<Button size="icon-xs" variant="ghost" title="Delete this document" onClick={onDelete} className="cursor-pointer">
<Trash2 />
</Button>
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, ChevronDown, ChevronRight, FilePlus, FolderPlus, Search, X } from 'lucide-react'
import { Archive, BookOpen, ChevronDown, ChevronRight, FilePlus, FolderPlus, Search, X } from 'lucide-react'
import { toast } from 'sonner'
import { scanApi } from '@/api/client'
@@ -10,6 +10,7 @@ import { useDesignStore } from '@/stores/designStore'
import type { InventoryEntry } from '@/types'
import { cn } from '@/lib/utils'
import { formatRelative } from '@/utils/timeFormat'
import { downloadAllDocs, downloadDoc } from '../export'
import { isOverdue } from '../frontmatter'
import { driftedIds, useDocsStore } from '../store'
import {
@@ -95,6 +96,7 @@ export function DocumentationView() {
const [regenerateOpen, setRegenerateOpen] = useState(false)
const [regenerating, setRegenerating] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [exporting, setExporting] = useState(false)
useEffect(() => {
void loadDocs()
@@ -234,6 +236,20 @@ export function DocumentationView() {
toast.success('Document deleted')
}, [openDoc, remove])
// The whole space, zipped server-side: the tree only holds summaries, so the
// bodies to write are not in the browser yet.
const handleExportAll = useCallback(async () => {
setExporting(true)
try {
await downloadAllDocs()
toast.success('Documentation exported')
} catch {
toast.error('Could not export the documentation')
} finally {
setExporting(false)
}
}, [])
const handleRegenerate = useCallback(async () => {
if (!openDoc) return
setRegenerating(true)
@@ -376,7 +392,13 @@ export function DocumentationView() {
if (doc) await open(doc.id)
}}
trigger={
<Button size="icon-xs" variant="ghost" aria-label="New document" title="New document">
<Button
size="icon-xs"
variant="ghost"
aria-label="New document"
title="New document"
data-tour="docs-new"
>
<FilePlus />
</Button>
}
@@ -436,7 +458,10 @@ export function DocumentationView() {
)}
</div>
<p className="mt-3 px-2 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/50">
<p
data-tour="docs-devices"
className="mt-3 px-2 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/50"
>
Devices
</p>
<DocTreeGroups
@@ -449,16 +474,27 @@ export function DocumentationView() {
/>
</div>
{coverage && (
<div className="flex items-center border-t border-border px-2 py-1.5">
<div className="flex items-center gap-1 border-t border-border px-2 py-1.5">
<Button
size="xs"
variant="ghost"
className="cursor-pointer gap-1 px-1.5"
title="Download every document as a zip of Markdown files"
disabled={exporting || docs.length === 0}
onClick={() => void handleExportAll()}
>
<Archive size={12} />
{exporting ? 'Exporting…' : 'Export all'}
</Button>
{coverage && (
<span
className="ml-auto text-[10px] tabular-nums text-muted-foreground/70"
title={`${coverage.documented} of ${coverage.devices} devices documented · ${coverage.header_only} still only the generated header`}
>
{coverage.documented}/{coverage.devices}
</span>
</div>
)}
)}
</div>
</aside>
<div
@@ -528,6 +564,7 @@ export function DocumentationView() {
onMarkReviewed={() => void markReviewed(openDoc.id)}
onSetTags={(tags) => void setTags(tags)}
onRegenerate={() => setRegenerateOpen(true)}
onDownload={() => downloadDoc(openDoc)}
onDelete={() => void handleDelete()}
onOpenDoc={(id) => void open(id)}
onCreateFromLink={async (label) => {
+68
View File
@@ -0,0 +1,68 @@
import { documentsApi } from '@/api/client'
import type { Doc } from './types'
/**
* Downloading documentation.
*
* A single page needs no server: `doc.body` *is* the file — the whole markdown
* document, YAML frontmatter included — so the viewer writes what it is already
* holding. Exporting everything does need the server, because the tree only
* carries summaries and the bodies were never loaded.
*/
/** Fall back on the title when a document predates its slug being set. */
function segment(raw: string): string {
const cleaned = raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^[-.]+|[-.]+$/g, '')
return cleaned || 'untitled'
}
export function docFilename(doc: Pick<Doc, 'slug' | 'title'>): string {
return `${segment(doc.slug || doc.title)}.md`
}
/** Hand a blob to the browser as a download, then release the object URL. */
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
}
/** One document as the `.md` file it already is. */
export function downloadDoc(doc: Pick<Doc, 'slug' | 'title' | 'body'>): void {
downloadBlob(new Blob([doc.body ?? ''], { type: 'text/markdown;charset=utf-8' }), docFilename(doc))
}
/**
* The `filename=` the server put on the archive, so the date in it is the
* server's and not a second one computed here.
*/
export function filenameFromDisposition(header: unknown, fallback: string): string {
if (typeof header !== 'string') return fallback
const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header)
if (!match) return fallback
try {
return decodeURIComponent(match[1].trim()) || fallback
} catch {
// A name that is not valid percent-encoding is still a usable name.
return match[1].trim() || fallback
}
}
/** Every document, as a zip of `.md` files mirroring the tree. */
export async function downloadAllDocs(): Promise<void> {
const res = await documentsApi.export()
const name = filenameFromDisposition(
res.headers?.['content-disposition'],
'homelable-documentation.zip',
)
downloadBlob(new Blob([res.data], { type: 'application/zip' }), name)
}
@@ -1,93 +0,0 @@
import { describe, it, expect } from 'vitest'
import { generateMarkdownTable } from '../exportMarkdown'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
const makeNode = (overrides: Partial<NodeData> = {}, id = '1'): Node<NodeData> => ({
id,
type: overrides.type ?? 'server',
position: { x: 0, y: 0 },
data: { label: 'Test', type: 'server', status: 'online', services: [], ...overrides },
})
describe('generateMarkdownTable', () => {
it('returns empty string for empty node list', () => {
expect(generateMarkdownTable([])).toBe('')
})
it('excludes groupRect nodes', () => {
const nodes = [makeNode({ type: 'groupRect', label: 'Zone' })]
expect(generateMarkdownTable(nodes)).toBe('')
})
it('generates header + separator + row', () => {
const nodes = [makeNode({ label: 'Router', type: 'router', ip: '192.168.1.1', status: 'online' })]
const md = generateMarkdownTable(nodes)
const lines = md.split('\n')
expect(lines[0]).toContain('Label')
expect(lines[0]).toContain('IP')
expect(lines[1]).toContain('---')
expect(lines[2]).toContain('Router')
expect(lines[2]).toContain('192.168.1.1')
})
it('uses — for missing fields', () => {
const nodes = [makeNode({ label: 'Node', type: 'generic', ip: undefined, hostname: undefined })]
const md = generateMarkdownTable(nodes)
expect(md).toContain('—')
})
it('lists services as name:port pairs', () => {
const nodes = [makeNode({
label: 'Server',
services: [{ port: 80, protocol: 'tcp', service_name: 'nginx' }, { port: 443, protocol: 'tcp', service_name: 'https' }],
})]
const md = generateMarkdownTable(nodes)
expect(md).toContain('nginx:80')
expect(md).toContain('https:443')
})
it('escapes pipe characters in cell values', () => {
const nodes = [makeNode({ label: 'A|B' })]
const md = generateMarkdownTable(nodes)
expect(md).toContain('A\\|B')
})
it('escapes backslashes before pipes so the escape char is not ambiguous', () => {
const nodes = [makeNode({ label: 'A\\|B' })]
const md = generateMarkdownTable(nodes)
// backslash doubled, then the literal pipe escaped
expect(md).toContain('A\\\\\\|B')
})
it('collapses newlines in cell values so they do not break the table', () => {
const nodes = [makeNode({ label: 'line1\nline2', hostname: 'a\r\nb' })]
const lines = generateMarkdownTable(nodes).split('\n')
// header + separator + exactly one data row (no extra line from the value)
expect(lines).toHaveLength(3)
expect(lines[2]).toContain('line1 line2')
expect(lines[2]).toContain('a b')
})
it('escapes pipe characters inside service names', () => {
const nodes = [makeNode({
label: 'Server',
services: [{ port: 80, protocol: 'tcp', service_name: 'web|proxy' }],
})]
const lines = generateMarkdownTable(nodes).split('\n')
// header + separator + exactly one data row — the pipe must not add a column
expect(lines).toHaveLength(3)
expect(lines[2]).toContain('web\\|proxy')
})
it('generates one row per non-groupRect node', () => {
const nodes = [
makeNode({ type: 'server', label: 'A' }, '1'),
makeNode({ type: 'router', label: 'B' }, '2'),
makeNode({ type: 'groupRect', label: 'Zone' }, '3'),
]
const lines = generateMarkdownTable(nodes).split('\n')
// header + separator + 2 data rows
expect(lines).toHaveLength(4)
})
})
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { deviceName, factCount, suggestWinner } from '@/utils/mergeWinner'
import type { InventoryEntry } from '@/types'
function entry(over: Partial<InventoryEntry>): InventoryEntry {
return {
id: 'x', ip: null, mac: null, hostname: null, os: null, services: [],
suggested_type: null, status: 'pending', discovered_at: '2026-01-01T00:00:00Z',
...over,
} as InventoryEntry
}
describe('suggestWinner', () => {
it('prefers the row carrying an IEEE — imports and links key on it', () => {
const devices = [
entry({ id: 'canvas', canvas_count: 1, discovered_at: '2025-01-01T00:00:00Z' }),
entry({ id: 'proxmox', ieee_address: 'pve-pve1-130' }),
]
expect(suggestWinner(devices)).toBe('proxmox')
})
it('falls back to a row that is already drawn on a canvas', () => {
const devices = [
entry({ id: 'stub' }),
entry({ id: 'drawn', canvas_count: 2, discovered_at: '2026-06-01T00:00:00Z' }),
]
expect(suggestWinner(devices)).toBe('drawn')
})
it('falls back to the oldest row when nothing else separates them', () => {
const devices = [
entry({ id: 'young', discovered_at: '2026-06-01T00:00:00Z' }),
entry({ id: 'old', discovered_at: '2025-02-01T00:00:00Z' }),
]
expect(suggestWinner(devices)).toBe('old')
})
it('is undefined for an empty selection', () => {
expect(suggestWinner([])).toBeUndefined()
})
})
describe('factCount', () => {
it('counts the scalars, services and properties a row knows', () => {
const rich = entry({
ip: '192.168.1.62', mac: 'bc:24:11:95:af:8c', hostname: 'n8n',
services: [{ port: 22, protocol: 'tcp', service_name: 'ssh' }] as InventoryEntry['services'],
properties: [{ key: 'VMID', value: '130' }] as InventoryEntry['properties'],
})
expect(factCount(rich)).toBe(5)
expect(factCount(entry({}))).toBe(0)
})
})
describe('deviceName', () => {
it('prefers the name the user gave the device', () => {
expect(deviceName(entry({ label: 'n8n', hostname: 'ct130', ip: '10.0.0.1' }))).toBe('n8n')
expect(deviceName(entry({ hostname: 'ct130', ip: '10.0.0.1' }))).toBe('ct130')
expect(deviceName(entry({}))).toBe('device')
})
})
-49
View File
@@ -1,49 +0,0 @@
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
const EMPTY = '—'
function cell(v: string | null | undefined): string {
if (!v) return EMPTY
// Escape backslashes first, then pipes, and collapse newlines so they don't break the table
return v
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, ' ')
}
export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
const rows = nodes
.filter((n) => n.data.type !== 'groupRect')
.map((n) => {
const d = n.data
const services = d.services?.length
? d.services.map((s) => {
const port = s.port != null ? `:${s.port}` : ''
const path = s.path?.trim() ? s.path.trim() : ''
return `${s.service_name}${port}${path}`
}).join(', ')
: EMPTY
return [
cell(d.label),
cell(d.type),
cell(d.ip),
cell(d.hostname),
cell(d.status),
cell(services),
]
})
if (rows.length === 0) return ''
const headers = ['Label', 'Type', 'IP', 'Hostname', 'Status', 'Services']
const separator = headers.map(() => '---')
const lines = [
`| ${headers.join(' | ')} |`,
`| ${separator.join(' | ')} |`,
...rows.map((r) => `| ${r.join(' | ')} |`),
]
return lines.join('\n')
}
+29
View File
@@ -0,0 +1,29 @@
import type { InventoryEntry } from '@/types'
/** The name the inventory prints for a device — the one the user gave it first. */
export function deviceName(d: InventoryEntry): string {
return d.label ?? d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'
}
/**
* The row the others should fold into, by default.
*
* Mirrors the backend's own precedence (`device_merge._pick_winner`): an IEEE
* first — the Proxmox import and the mesh link graph key on it, so that row is
* where every other writer comes back — then a row that is already drawn on a
* canvas, then the oldest. Only a suggestion: the user picks in the end.
*/
export function suggestWinner(devices: InventoryEntry[]): string | undefined {
return [...devices].sort((a, b) => (
(a.ieee_address ? 0 : 1) - (b.ieee_address ? 0 : 1)
|| ((b.canvas_count ?? 0) > 0 ? 1 : 0) - ((a.canvas_count ?? 0) > 0 ? 1 : 0)
|| a.discovered_at.localeCompare(b.discovered_at)
|| a.id.localeCompare(b.id)
))[0]?.id
}
/** How much a row knows — shown so the user can tell the rich one from the stub. */
export function factCount(d: InventoryEntry): number {
const scalars = [d.ip, d.mac, d.hostname, d.os, d.vendor, d.model, d.notes, d.ieee_address]
return scalars.filter(Boolean).length + (d.services?.length ?? 0) + (d.properties?.length ?? 0)
}
@@ -17,6 +17,31 @@ describe('getSteps', () => {
expect(ids).not.toContain('scan-history')
expect(ids).not.toContain('inventory')
expect(ids).not.toContain('imports')
// Documentation has nowhere to store a document without the backend.
expect(ids).not.toContain('docs')
expect(ids).not.toContain('docs-write')
expect(ids).not.toContain('docs-devices')
})
it('walks the Documentation section on anchors the view carries', () => {
const docs = STEPS.filter((s) => s.id.startsWith('docs'))
expect(docs.map((s) => s.id)).toEqual(['docs', 'docs-write', 'docs-devices'])
expect(docs.map((s) => s.anchor)).toEqual([
'[data-tour="documentation"]',
'[data-tour="docs-new"]',
'[data-tour="docs-devices"]',
])
// Every one of them needs the section open: `closeAll` puts the view back
// to the canvas before each step, so the action cannot be dropped from the
// second and third.
expect(docs.every((s) => s.action === 'openDocumentation')).toBe(true)
expect(docs.every((s) => s.mode === 'full')).toBe(true)
})
it('documents the homelab once it has been mapped, before the styling step', () => {
const ids = STEPS.map((s) => s.id)
expect(ids.indexOf('docs')).toBeGreaterThan(ids.indexOf('rack'))
expect(ids.indexOf('docs-devices')).toBeLessThan(ids.indexOf('style'))
})
it('keeps the rack step in both modes — a rack canvas needs no backend', () => {
+2
View File
@@ -19,6 +19,8 @@ export interface WalkthroughActionApi {
selectTwoNodes: () => void
openStyle: () => void
openZigbeeImport: () => void
/** Swaps the whole main pane for the Documentation section (`closeAll` swaps it back). */
openDocumentation: () => void
}
const WalkthroughActionsContext = createContext<WalkthroughActionApi | null>(null)
+27
View File
@@ -95,6 +95,33 @@ export const STEPS: TourStep[] = [
placement: 'right',
mode: 'all',
},
{
id: 'docs',
title: 'Document your homelab',
body: 'Documentation sits beside the canvas, not inside a design: every device gets its own page, and the Library holds the runbooks, overviews and decisions that span several of them. A device is documented once and reads the same wherever it is drawn.',
anchor: '[data-tour="documentation"]',
placement: 'right',
action: 'openDocumentation',
mode: 'full',
},
{
id: 'docs-write',
title: 'Start from a template',
body: 'New document opens a template picker — Runbook, Incident, Decision (ADR), Network overview and more, each with its sections already laid out. Write in Markdown, type [[ to link another document, and every page lists what links back to it. Earlier versions stay one click away.',
anchor: '[data-tour="docs-new"]',
placement: 'right',
action: 'openDocumentation',
mode: 'full',
},
{
id: 'docs-devices',
title: 'A page per device',
body: 'Under Devices, every inventory entry has a page whose header is generated from the database — regenerate it after a change (the old body is kept in the history). Pivot the tree by zone, type, subnet, rack or tag, and reach a page straight from a node or an inventory row.',
anchor: '[data-tour="docs-devices"]',
placement: 'right',
action: 'openDocumentation',
mode: 'full',
},
{
id: 'style',
title: 'Make it yours',
+45 -1
View File
@@ -1,7 +1,50 @@
import json
import httpx
from .config import settings
class BackendError(RuntimeError):
"""A non-2xx answer from the backend, carrying the reason it gave.
httpx's own error text stops at the status line and the URL, so FastAPI's
`{"detail": ...}` — the half that says what to do about it — never reached
the calling client. A rejected connection point, a duplicate device, a
missing design: all of them arrived as a bare '422 Unprocessable Content'.
"""
def __init__(self, method: str, path: str, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
suffix = f": {detail}" if detail else ""
super().__init__(f"{method} {path} failed with {status_code}{suffix}")
# Enough for a FastAPI detail — a structured one included — without pasting a
# whole HTML error page into the client's context.
_MAX_DETAIL = 500
def _detail_of(resp: httpx.Response) -> str:
"""The backend's explanation for a failed response, as a single string.
`detail` is a string for most errors and an object for the structured ones
(the approve-duplicate prompt); anything else falls back to the raw body.
"""
try:
payload = resp.json()
except ValueError:
text = resp.text.strip()
else:
detail = payload.get("detail") if isinstance(payload, dict) else None
if detail is None:
text = json.dumps(payload)
else:
text = detail if isinstance(detail, str) else json.dumps(detail)
return text[:_MAX_DETAIL]
class BackendClient:
def __init__(self):
self._client: httpx.AsyncClient | None = None
@@ -19,7 +62,8 @@ class BackendClient:
async def request(self, method: str, path: str, **kwargs) -> dict:
resp = await self._client.request(method, path, **kwargs)
resp.raise_for_status()
if resp.is_error:
raise BackendError(method, path, resp.status_code, _detail_of(resp))
if resp.status_code == 204:
return {}
return resp.json()
+61 -1
View File
@@ -62,6 +62,15 @@ _NODE_FIELDS = {
# (`merge_properties` / `apply_view` in backend/app/services/inventory_sync.py).
# A property with no `key` collapses with every other keyless one — send two
# and the node draws one.
# Connection points. The count is per side; the IDs an edge references are
# derived from it — slot 0 is the bare side name, slot N >= 1 is
# '{side}-{N + 1}' (frontend/src/utils/handleUtils.ts). Lowering a count
# moves the edges that used the dropped handles back to the side's slot 0.
"top_handles": {"type": "integer", "minimum": 0, "maximum": 64, "description": "Connection points on the top side (default 1). Their IDs are 'top', 'top-2', 'top-3', ..."},
"bottom_handles": {"type": "integer", "minimum": 0, "maximum": 64, "description": "Connection points on the bottom side (default 1). Their IDs are 'bottom', 'bottom-2', 'bottom-3', ..."},
"left_handles": {"type": "integer", "minimum": 0, "maximum": 64, "description": "Connection points on the left side (default 0). Their IDs are 'left', 'left-2', 'left-3', ..."},
"right_handles": {"type": "integer", "minimum": 0, "maximum": 64, "description": "Connection points on the right side (default 0). Their IDs are 'right', 'right-2', 'right-3', ..."},
"show_port_numbers": {"type": "boolean", "description": "Number each connection point on the node card."},
"properties": {
"type": "array",
"description": "Key/value metadata shown on the node. `key` is the identity: two properties sharing one are the same property.",
@@ -90,6 +99,33 @@ _ZONE_COLOR_FIELDS = {
"text_color": {"type": "string", "description": "Label colour, e.g. '#e6edf3'."},
}
# Which connection point each end of an edge attaches to. The IDs are the node's
# per-side handle IDs (see the *_handles fields above): 'bottom', 'bottom-2',
# 'left-3', ... Omit both on create and the backend picks them from the two nodes'
# relative positions (_auto_handles in backend/app/api/routes/edges.py).
#
# The node must already have the connection point — left/right default to none —
# or the backend answers 422 rather than storing an edge the canvas cannot draw.
# Presentation, mirroring the backend EdgeBase (backend/app/schemas/edges.py) and
# the EdgeData types the canvas draws from (frontend/src/types/index.ts). Every one
# is optional: an omitted field keeps the edge type's own preset.
_EDGE_STYLE_FIELDS = {
"animated": {"type": "string", "enum": ["none", "basic", "snake", "flow"], "description": "Line animation. 'basic' is the dashed march, 'snake' a travelling segment, 'flow' a continuous drift."},
"custom_color": {"type": "string", "description": "Line colour override, e.g. '#00d4ff'. Omit to use the edge type's colour."},
"path_style": {"type": "string", "enum": ["bezier", "smooth"], "description": "How the line is routed between the two connection points."},
"line_style": {"type": "string", "enum": ["solid", "dashed", "dotted"], "description": "How the line itself is stroked."},
"width_mult": {"type": "number", "minimum": 1, "maximum": 4, "description": "Stroke-width multiplier (1-4x) on the edge type's base width."},
"marker_start": {"type": "string", "enum": ["none", "arrow", "arrow-open", "circle", "diamond", "square"], "description": "Marker drawn at the source end."},
"marker_end": {"type": "string", "enum": ["none", "arrow", "arrow-open", "circle", "diamond", "square"], "description": "Marker drawn at the target end."},
"vlan_id": {"type": "integer", "description": "VLAN tag carried by the link. Shown on the edge for a 'vlan' type."},
"speed": {"type": "string", "description": "Link speed as it should read on the canvas, e.g. '10G' or '2.5 Gbps'."},
}
_EDGE_HANDLE_FIELDS = {
"source_handle": {"type": "string", "description": "Connection point on the source node, e.g. 'bottom' or 'right-2'. The node must have it already — raise that side's *_handles first, or the call is rejected. Omit to let the server pick."},
"target_handle": {"type": "string", "description": "Connection point on the target node, e.g. 'top' or 'left-3'. The node must have it already — raise that side's *_handles first, or the call is rejected. Omit to let the server pick."},
}
# Optional design/canvas selector. The backend attaches nodes/edges to the first
# design when design_id is omitted (see backend nodes.py / edges.py), so these
# tools stay backward compatible; pass design_id to target a specific canvas.
@@ -129,7 +165,7 @@ def _build_tools() -> list[Tool]:
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
Tool(name="create_edge", description="Create a network link between two nodes. Pass source_handle/target_handle to choose which connection points it attaches to; omit them and the server picks from the nodes' relative positions.", inputSchema={
"type": "object",
"required": ["source", "target"],
"properties": {
@@ -137,9 +173,26 @@ def _build_tools() -> list[Tool]:
"target": {"type": "string"},
"type": {"type": "string", "enum": EDGE_TYPES, "default": "ethernet"},
"label": {"type": "string"},
**_EDGE_STYLE_FIELDS,
**_EDGE_HANDLE_FIELDS,
**_DESIGN_ID_FIELD,
},
}),
Tool(name="update_edge", description="Update an existing link: its type, label, styling (animation, colour, line/path style, width, endpoint markers), or which connection points it attaches to. Call get_canvas to discover edge ids.", inputSchema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string", "description": "Edge id."},
"type": {"type": "string", "enum": EDGE_TYPES},
"label": {"type": "string"},
**_EDGE_STYLE_FIELDS,
**_EDGE_HANDLE_FIELDS,
},
}),
Tool(name="list_edges", description="List every link with its full detail — type, label, waypoints, and the source_handle/target_handle each end attaches to. get_canvas slims edges down to id/source/target/type/label; use this when the connection points or styling matter.", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="delete_edge", description="Delete a network link", inputSchema={
"type": "object",
"required": ["id"],
@@ -371,6 +424,13 @@ async def _dispatch(name: str, args: dict) -> dict:
if name == "create_edge":
return await backend.post("/api/v1/edges", args)
if name == "update_edge":
edge_id = args.pop("id")
return await backend.patch(f"/api/v1/edges/{edge_id}", args)
if name == "list_edges":
return await backend.get("/api/v1/edges")
if name == "delete_edge":
return await backend.delete(f"/api/v1/edges/{args['id']}")
+92
View File
@@ -0,0 +1,92 @@
"""The backend's own explanation must survive the trip to the MCP client.
httpx's error text stops at the status line, so a rejected call used to reach the
caller as a bare "Client error '422 Unprocessable Content'" — the reason, which is
the only part that says what to do next, was dropped.
"""
import httpx
import pytest
from app.backend_client import BackendClient, BackendError
def _client_answering(response: httpx.Response) -> BackendClient:
backend = BackendClient()
backend._client = httpx.AsyncClient(
base_url="http://backend",
transport=httpx.MockTransport(lambda _request: response),
)
return backend
@pytest.mark.anyio
async def test_a_string_detail_reaches_the_caller():
detail = "source_handle 'right-2' does not exist: node n1 has 0 connection point(s) on its right side."
backend = _client_answering(httpx.Response(422, json={"detail": detail}))
with pytest.raises(BackendError) as exc:
await backend.post("/api/v1/edges", {})
assert detail in str(exc.value)
assert "422" in str(exc.value)
assert exc.value.status_code == 422
assert exc.value.detail == detail
@pytest.mark.anyio
async def test_a_structured_detail_is_serialized():
# The approve-duplicate prompt answers with an object, not a string.
backend = _client_answering(httpx.Response(409, json={"detail": {"code": "duplicate", "node_id": "n1"}}))
with pytest.raises(BackendError) as exc:
await backend.post("/api/v1/scan/pending/d1/approve", {})
assert "duplicate" in str(exc.value)
assert "n1" in str(exc.value)
@pytest.mark.anyio
async def test_a_non_json_body_falls_back_to_its_text():
backend = _client_answering(httpx.Response(500, text="Internal Server Error"))
with pytest.raises(BackendError) as exc:
await backend.get("/api/v1/nodes")
assert "Internal Server Error" in str(exc.value)
@pytest.mark.anyio
async def test_the_method_and_path_are_named():
backend = _client_answering(httpx.Response(404, json={"detail": "Edge not found"}))
with pytest.raises(BackendError) as exc:
await backend.patch("/api/v1/edges/e1", {})
assert "PATCH" in str(exc.value)
assert "/api/v1/edges/e1" in str(exc.value)
@pytest.mark.anyio
async def test_a_long_detail_is_truncated():
# An HTML error page must not land whole in the client's context.
backend = _client_answering(httpx.Response(500, text="x" * 5000))
with pytest.raises(BackendError) as exc:
await backend.get("/api/v1/nodes")
assert len(exc.value.detail) == 500
@pytest.mark.anyio
async def test_a_204_still_returns_an_empty_dict():
backend = _client_answering(httpx.Response(204))
assert await backend.delete("/api/v1/edges/e1") == {}
@pytest.mark.anyio
async def test_a_success_is_returned_unchanged():
backend = _client_answering(httpx.Response(200, json={"id": "e1"}))
assert await backend.get("/api/v1/edges/e1") == {"id": "e1"}
+117
View File
@@ -688,3 +688,120 @@ def test_update_node_documents_that_status_is_observed_not_set():
tool = next(t for t in TOOLS if t.name == "update_node")
description = tool.inputSchema["properties"]["status"]["description"]
assert "status checker" in description and "unknown" in description
# ---------------------------------------------------------------------------
# Connection points (#435)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("tool_name", ["create_node", "update_node"])
def test_node_schemas_expose_the_handle_counts(tool_name):
# They were readable through get_node but not settable — the whole of #435.
props = _tool_schema(tool_name)
for side in ("top", "bottom", "left", "right"):
field = f"{side}_handles"
assert field in props, f"{tool_name} schema missing {field}"
assert props[field]["minimum"] == 0
assert props[field]["maximum"] == 64
assert "show_port_numbers" in props
def test_edge_schemas_expose_the_connection_points():
for tool_name in ("create_edge", "update_edge"):
props = _tool_schema(tool_name)
assert "source_handle" in props, f"{tool_name} schema missing source_handle"
assert "target_handle" in props, f"{tool_name} schema missing target_handle"
def test_update_edge_schema_cannot_repoint_or_move_an_edge():
# EdgeUpdate carries neither source/target nor design_id: an edge is deleted
# and recreated to change what it connects, never patched.
props = _tool_schema("update_edge")
assert props.keys() >= {"id"}
for absent in ("source", "target", "design_id"):
assert absent not in props
@pytest.mark.anyio
async def test_update_node_forwards_handle_counts(mock_backend):
await _dispatch("update_node", {"id": "42", "left_handles": 3, "right_handles": 0})
mock_backend.patch.assert_called_once_with(
"/api/v1/nodes/42", {"left_handles": 3, "right_handles": 0}
)
@pytest.mark.anyio
async def test_create_node_forwards_handle_counts(mock_backend):
args = {"type": "switch", "label": "sw1", "bottom_handles": 8, "show_port_numbers": True}
await _dispatch("create_node", dict(args))
mock_backend.post.assert_called_once_with("/api/v1/nodes", args)
@pytest.mark.anyio
async def test_create_edge_forwards_explicit_handles(mock_backend):
args = {"source": "1", "target": "2", "source_handle": "bottom-3", "target_handle": "left"}
await _dispatch("create_edge", dict(args))
mock_backend.post.assert_called_once_with("/api/v1/edges", args)
@pytest.mark.anyio
async def test_update_edge_patches_by_id(mock_backend):
await _dispatch("update_edge", {"id": "e7", "source_handle": "right-2", "label": "uplink"})
mock_backend.patch.assert_called_once_with(
"/api/v1/edges/e7", {"source_handle": "right-2", "label": "uplink"}
)
@pytest.mark.anyio
async def test_list_edges_returns_the_unslimmed_edges(mock_backend):
# get_canvas slims source_handle/target_handle away; this is how they're read.
edge = {"id": "e1", "source": "1", "target": "2", "source_handle": "bottom-2", "target_handle": "top"}
mock_backend.get = AsyncMock(return_value=[edge])
result = await _dispatch("list_edges", {})
mock_backend.get.assert_called_once_with("/api/v1/edges")
assert result == [edge]
def test_edge_schemas_expose_the_styling_fields():
# The backend has always accepted these; only the canvas UI could set them.
for tool_name in ("create_edge", "update_edge"):
props = _tool_schema(tool_name)
for field in ("animated", "custom_color", "path_style", "line_style",
"width_mult", "marker_start", "marker_end", "vlan_id", "speed"):
assert field in props, f"{tool_name} schema missing {field}"
def test_edge_style_enums_match_what_the_backend_normalizes():
# normalize_animated / normalize_marker (backend/app/schemas/utils.py) coerce
# anything outside these sets to 'none', so an advertised value that isn't
# there would silently do nothing.
props = _tool_schema("create_edge")
assert set(props["animated"]["enum"]) == {"none", "basic", "snake", "flow"}
markers = {"none", "arrow", "arrow-open", "circle", "diamond", "square"}
assert set(props["marker_start"]["enum"]) == markers
assert set(props["marker_end"]["enum"]) == markers
assert set(props["path_style"]["enum"]) == {"bezier", "smooth"}
assert set(props["line_style"]["enum"]) == {"solid", "dashed", "dotted"}
assert (props["width_mult"]["minimum"], props["width_mult"]["maximum"]) == (1, 4)
@pytest.mark.anyio
async def test_create_edge_forwards_the_styling_fields(mock_backend):
args = {
"source": "1", "target": "2", "type": "wifi",
"animated": "basic", "custom_color": "#00d4ff", "line_style": "dashed",
"width_mult": 2, "marker_end": "arrow", "vlan_id": 20, "speed": "10G",
}
await _dispatch("create_edge", dict(args))
mock_backend.post.assert_called_once_with("/api/v1/edges", args)
@pytest.mark.anyio
async def test_update_edge_forwards_the_styling_fields(mock_backend):
await _dispatch("update_edge", {"id": "e7", "animated": "flow", "path_style": "smooth"})
mock_backend.patch.assert_called_once_with(
"/api/v1/edges/e7", {"animated": "flow", "path_style": "smooth"}
)