Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ab20a9b9 | ||
|
|
2f9889921e | ||
|
|
8dba259796 | ||
|
|
783ca87acd | ||
|
|
71e78b4279 | ||
|
|
ac8aea7467 | ||
|
|
08d85a40c1 | ||
|
|
e247483d9b | ||
|
|
acac515d18 | ||
|
|
5273189315 | ||
|
|
fa192beac9 | ||
|
|
7aa322da35 |
+1
-1
@@ -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']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" /> Homelable</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Self-hosted homelab infrastructure visualization, scanning & live monitoring</strong>
|
||||
<strong>Self-hosted homelab infrastructure visualization, documentation, scanning & 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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) == {}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
Generated
+3
-3
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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']}")
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user