Compare commits

..
12 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
38 changed files with 1965 additions and 195 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']
+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>
+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.
+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)
+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()
@@ -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")
+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"
+3 -3
View File
@@ -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",
+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}
+3
View File
@@ -358,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',
@@ -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)
})
})
-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')
}
@@ -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"}
)