Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ab20a9b9 | ||
|
|
2f9889921e | ||
|
|
8dba259796 | ||
|
|
783ca87acd | ||
|
|
71e78b4279 | ||
|
|
ac8aea7467 | ||
|
|
08d85a40c1 | ||
|
|
e247483d9b | ||
|
|
acac515d18 | ||
|
|
5273189315 | ||
|
|
fa192beac9 | ||
|
|
7aa322da35 | ||
|
|
574b84d843 | ||
|
|
0ffece7a0d | ||
|
|
46dac1a89f |
+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']
|
||||
|
||||
@@ -5,6 +5,18 @@ All notable changes to **Homelable** are documented here.
|
||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [3.4.1] - 2026-09-07
|
||||
|
||||
### Features
|
||||
|
||||
- Duplicate Device Inventory rows can be merged. A **Merge** action in the inventory's select mode lets you pick the survivor — the only way to collapse rows that share no address at all, since a name is not an identity — and `reconcile_duplicates` now runs at the end of a Proxmox import and of a scan to collapse what a shared MAC proves (never a shared IP alone, never across two distinct IEEEs). Merging keeps everything: the survivor fills its gaps from the others, addresses, services, properties and sources union, and canvas nodes, rack mounts, documents and mesh links are re-pointed before the extra rows go. (#424)
|
||||
|
||||
### Fixes
|
||||
|
||||
- `dedupe_nodes_by_device` deleted duplicate nodes while `rack_devices.node_id` and `documents.node_id` still named them; SQLite runs with foreign keys off, so the declared `ON DELETE SET NULL` never fired. (#424)
|
||||
- The scanner's duplicate collapse merges instead of deleting inventory rows outright, which lost their facts and orphaned whatever drew them. Its guard against removing a second approved row is unchanged. (#424)
|
||||
- The merge dialog is wide enough to read an IEEE. The addresses are what the choice is made on, and a long name was clipped at the edge of the dialog; the address line now wraps instead of truncating, and the list grows with the viewport. (#424)
|
||||
|
||||
## [3.4.0] - 2026-09-07
|
||||
|
||||
### Features
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -33,6 +33,7 @@ from app.schemas.proxmox import (
|
||||
ProxmoxTestConnectionResponse,
|
||||
)
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.services.device_merge import reconcile_duplicates
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.inventory_sync import attach_device_ids
|
||||
from app.services.mac_utils import normalize_mac
|
||||
@@ -321,6 +322,12 @@ async def _persist_pending_import(
|
||||
en.left_handles = max(en.left_handles or 0, 1)
|
||||
en.right_handles = max(en.right_handles or 0, 1)
|
||||
|
||||
# Fold in any row this import just proved to be the same device: a guest
|
||||
# matched here by its synthetic ieee can share a MAC or an IP with a row an
|
||||
# earlier scan (or an older canvas node) minted before either side carried
|
||||
# the address that would have matched them.
|
||||
await reconcile_duplicates(db)
|
||||
|
||||
links_recorded = await _replace_links(db, edges_raw, cluster_pairs)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.schemas.scan import (
|
||||
InventoryDeviceUpdate,
|
||||
ScanRunResponse,
|
||||
)
|
||||
from app.services.device_merge import merge_devices
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.doc_links import unlink_documents
|
||||
from app.services.inventory_sync import find_device_for, merge_properties, merge_services
|
||||
@@ -117,6 +118,13 @@ def merge_mac_property(
|
||||
return out
|
||||
|
||||
|
||||
class MergeDevicesRequest(BaseModel):
|
||||
"""Which inventory rows fold into which. ``winner_id`` is the survivor."""
|
||||
|
||||
winner_id: str
|
||||
loser_ids: list[str]
|
||||
|
||||
|
||||
class BulkActionRequest(BaseModel):
|
||||
device_ids: list[str]
|
||||
# Target design for approved nodes. Falls back to the first design when
|
||||
@@ -621,6 +629,45 @@ async def list_proxmox_children(
|
||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||
|
||||
|
||||
@router.post("/pending/merge", response_model=InventoryDeviceResponse)
|
||||
async def merge_inventory_devices(
|
||||
payload: MergeDevicesRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> InventoryDevice:
|
||||
"""Fold several inventory rows into one, keeping ``winner_id``.
|
||||
|
||||
The manual half of the duplicate repair. Identity matching only runs when a
|
||||
row is created, so two rows describing one host — minted before either
|
||||
carried the address that would have matched them — stay separate forever.
|
||||
Where they share no address at all, no automatic rule can join them safely
|
||||
(a name is not an identity), and this is the only way to say "these are the
|
||||
same machine".
|
||||
|
||||
Non-destructive: facts are unioned onto the survivor and every canvas node,
|
||||
rack mount and document is re-pointed at it before the extras go.
|
||||
"""
|
||||
winner = await db.get(InventoryDevice, payload.winner_id)
|
||||
if winner is None:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
|
||||
loser_ids = [i for i in dict.fromkeys(payload.loser_ids) if i != winner.id]
|
||||
if not loser_ids:
|
||||
raise HTTPException(status_code=400, detail="Select at least two devices to merge")
|
||||
|
||||
losers = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.id.in_(loser_ids)))
|
||||
).scalars().all()
|
||||
missing = set(loser_ids) - {row.id for row in losers}
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
|
||||
await merge_devices(db, winner, list(losers))
|
||||
await db.commit()
|
||||
await db.refresh(winner)
|
||||
return (await _with_canvas_counts(db, [winner]))[0]
|
||||
|
||||
|
||||
@router.post("/pending/bulk-approve", response_model=dict)
|
||||
async def bulk_approve_devices(
|
||||
payload: BulkActionRequest,
|
||||
|
||||
@@ -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,447 @@
|
||||
"""Fold several `device_inventory` rows describing one host into a single row.
|
||||
|
||||
The inventory matches on identity *when a row is created* (``find_device_for``:
|
||||
ieee > ip > mac) and never looks again. A row minted while its facts were still
|
||||
incomplete — a canvas node saved before anyone typed its IP, a legacy node the
|
||||
3.3.0 backfill could only see by label — therefore stays a second row forever,
|
||||
even once the missing address arrives and both rows plainly describe the same
|
||||
machine. This module is the repair for that, in two shapes:
|
||||
|
||||
* :func:`merge_devices` — the user picks the survivor and what folds into it.
|
||||
The only way to collapse rows that share nothing an automatic rule can trust:
|
||||
a name is not an identity.
|
||||
* :func:`reconcile_duplicates` — run after an import, collapses only what a
|
||||
shared MAC proves. Never a shared IP alone: that is not an identity.
|
||||
|
||||
Merging is non-destructive by construction: facts are unioned, and every
|
||||
reference (`nodes`, `rack_devices`, `documents`, mesh links) is re-pointed onto
|
||||
the survivor *before* the extras go. That is what separates it from the
|
||||
scanner's older collapse, which deleted duplicate rows outright and left canvas
|
||||
nodes naming a row that no longer existed — SQLite runs with foreign keys off
|
||||
here, so the declared ``ON DELETE SET NULL`` never fires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Document, InventoryDevice, InventoryDeviceLink, Node, RackDevice
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.inventory_sync import (
|
||||
VIEW_LISTS,
|
||||
ip_tokens,
|
||||
merge_properties,
|
||||
merge_services,
|
||||
view_of_device,
|
||||
)
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Scalars the survivor fills from a loser when — and only when — it has none of
|
||||
# its own. `ip`, `mac`, the lists and the timestamps are merged by rule below.
|
||||
_FILL_SCALARS = (
|
||||
"hostname",
|
||||
"os",
|
||||
"suggested_type",
|
||||
"discovery_source",
|
||||
"friendly_name",
|
||||
"device_subtype",
|
||||
"model",
|
||||
"vendor",
|
||||
"lqi",
|
||||
"label",
|
||||
"type",
|
||||
"notes",
|
||||
"cpu_count",
|
||||
"cpu_model",
|
||||
"ram_gb",
|
||||
"disk_gb",
|
||||
"check_method",
|
||||
"check_target",
|
||||
"response_time_ms",
|
||||
"rack_faceplate_id",
|
||||
"rack_u_height",
|
||||
"rack_col_span",
|
||||
"rack_color",
|
||||
"rack_ports",
|
||||
)
|
||||
|
||||
|
||||
def _blank(value: Any) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def _newest(left: datetime | None, right: datetime | None) -> datetime | None:
|
||||
if left is None or right is None:
|
||||
return left or right
|
||||
return max(left, right)
|
||||
|
||||
|
||||
def _merged_ip(winner: str | None, loser: str | None) -> str | None:
|
||||
"""Union of both address lists, the survivor's own addresses first.
|
||||
|
||||
A device legitimately holds several addresses (a management VLAN, a second
|
||||
NIC), and dropping the loser's would undo the very match that identified it
|
||||
— the next import would mint the duplicate again.
|
||||
"""
|
||||
tokens = ip_tokens(winner)
|
||||
for token in ip_tokens(loser):
|
||||
if token not in tokens:
|
||||
tokens.append(token)
|
||||
return ", ".join(tokens) or None
|
||||
|
||||
|
||||
def _merged_status(winner: str, losers: list[InventoryDevice]) -> str:
|
||||
"""The lifecycle state the survivor carries.
|
||||
|
||||
``approved`` wins over everything: a canvas node or a rack mount is about to
|
||||
point at this row, and a row that is pending or hidden would file a drawn
|
||||
device back in the discovery queue or make it vanish from the inventory.
|
||||
"""
|
||||
if winner == "approved" or any(loser.status == "approved" for loser in losers):
|
||||
return "approved"
|
||||
return winner
|
||||
|
||||
|
||||
async def _merge_documents(db: AsyncSession, winner: InventoryDevice, loser_ids: list[str]) -> int:
|
||||
"""Re-point the losers' documents, or orphan them. Returns how many orphaned.
|
||||
|
||||
``documents.device_id`` is unique per device (a partial index built in
|
||||
``init_db``), so only one document can describe the survivor. The oldest of
|
||||
the losers' is adopted when the survivor has none; the rest are unlinked
|
||||
rather than deleted — what the user wrote outlives the row it described, and
|
||||
an orphan keeps its denormalized title and can be re-linked from the Library.
|
||||
"""
|
||||
docs = (
|
||||
await db.execute(
|
||||
select(Document)
|
||||
.where(Document.device_id.in_(loser_ids))
|
||||
.order_by(Document.created_at, Document.id)
|
||||
)
|
||||
).scalars().all()
|
||||
if not docs:
|
||||
return 0
|
||||
|
||||
has_own = (
|
||||
await db.execute(select(Document.id).where(Document.device_id == winner.id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
orphaned = 0
|
||||
for i, doc in enumerate(docs):
|
||||
if i == 0 and has_own is None:
|
||||
doc.device_id = winner.id
|
||||
continue
|
||||
doc.device_id = None
|
||||
orphaned += 1
|
||||
return orphaned
|
||||
|
||||
|
||||
async def _show_merged_facts(db: AsyncSession, winner: InventoryDevice) -> int:
|
||||
"""Make the survivor's facts visible on every canvas that draws it.
|
||||
|
||||
A node's ``display_view`` is a whitelist: ``apply_view`` appends anything the
|
||||
view does not list *hidden*, which is what keeps a service a later scan found
|
||||
off canvases nobody has enabled it on. After a merge that rule works against
|
||||
the user — a node that came from the poorer row lists only the poorer row's
|
||||
keys, so every service and property it just gained would arrive invisible,
|
||||
and the canvas would look exactly as bare as before the merge.
|
||||
|
||||
So each node drawing the survivor has the keys missing from its view appended
|
||||
as shown. Only *missing* keys: a fact this canvas listed as hidden was hidden
|
||||
on purpose and stays that way. Returns the number of nodes touched.
|
||||
"""
|
||||
nodes = (
|
||||
await db.execute(select(Node).where(Node.device_id == winner.id))
|
||||
).scalars().all()
|
||||
seeds = view_of_device(winner)
|
||||
touched = 0
|
||||
for node in nodes:
|
||||
view = dict(node.display_view or {})
|
||||
changed = False
|
||||
for kind in VIEW_LISTS:
|
||||
entries = view.get(kind)
|
||||
if not isinstance(entries, list):
|
||||
# No view at all for this list — `apply_view` already shows
|
||||
# everything the row holds, merged facts included.
|
||||
continue
|
||||
listed = {str(e.get("key")) for e in entries if isinstance(e, dict)}
|
||||
missing = [entry for entry in seeds[kind] if entry["key"] not in listed]
|
||||
if missing:
|
||||
view[kind] = [*entries, *missing]
|
||||
changed = True
|
||||
if changed:
|
||||
# Reassign rather than mutate: a JSON column tracks identity, not
|
||||
# in-place edits, so a mutated dict would never be written back.
|
||||
node.display_view = view
|
||||
touched += 1
|
||||
return touched
|
||||
|
||||
|
||||
async def _refresh_rack_mounts(db: AsyncSession, winner: InventoryDevice, losers: list[InventoryDevice]) -> None:
|
||||
"""Point the losers' rack mounts at the survivor and refresh what they copied.
|
||||
|
||||
A mount denormalizes the device's ``label`` so a rack still renders after an
|
||||
inventory purge. Re-pointing alone would leave it printing the name of a row
|
||||
that no longer exists, so a copy that still matches the loser is refreshed to
|
||||
the survivor's name — a label the user typed on the mount itself does not
|
||||
match and is left alone. The faceplate, its size and its ports are overlaid
|
||||
from the inventory row on load, and the merge already filled any the survivor
|
||||
lacked, so the plate keeps rendering as it did.
|
||||
"""
|
||||
loser_ids = [row.id for row in losers]
|
||||
mounts = (
|
||||
await db.execute(select(RackDevice).where(RackDevice.device_id.in_(loser_ids)))
|
||||
).scalars().all()
|
||||
stale = {
|
||||
name
|
||||
for row in losers
|
||||
for name in (row.label, row.friendly_name, row.hostname)
|
||||
if name
|
||||
}
|
||||
winner_name = winner.label or winner.friendly_name or winner.hostname
|
||||
for mount in mounts:
|
||||
mount.device_id = winner.id
|
||||
if winner_name and mount.label in stale:
|
||||
mount.label = winner_name
|
||||
|
||||
|
||||
async def _rewrite_links(db: AsyncSession, winner_ieee: str, loser_ieees: list[str]) -> None:
|
||||
"""Move mesh/Proxmox links off the losers' IEEEs onto the survivor's.
|
||||
|
||||
``device_inventory_links`` addresses endpoints by IEEE, not by row id, so a
|
||||
link left on a merged-away address stops resolving to anything. Rewriting
|
||||
can produce a self-link (both endpoints were the same device) or a duplicate
|
||||
pair; both are dropped.
|
||||
"""
|
||||
if not loser_ieees:
|
||||
return
|
||||
links = (
|
||||
await db.execute(
|
||||
select(InventoryDeviceLink).where(
|
||||
or_(
|
||||
InventoryDeviceLink.source_ieee.in_(loser_ieees),
|
||||
InventoryDeviceLink.target_ieee.in_(loser_ieees),
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
for link in links:
|
||||
if link.source_ieee in loser_ieees:
|
||||
link.source_ieee = winner_ieee
|
||||
if link.target_ieee in loser_ieees:
|
||||
link.target_ieee = winner_ieee
|
||||
await db.flush()
|
||||
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
all_links = (
|
||||
await db.execute(
|
||||
select(InventoryDeviceLink)
|
||||
.where(
|
||||
or_(
|
||||
InventoryDeviceLink.source_ieee == winner_ieee,
|
||||
InventoryDeviceLink.target_ieee == winner_ieee,
|
||||
)
|
||||
)
|
||||
.order_by(InventoryDeviceLink.discovered_at, InventoryDeviceLink.id)
|
||||
)
|
||||
).scalars().all()
|
||||
for link in all_links:
|
||||
key = (link.source_ieee, link.target_ieee, link.discovery_source)
|
||||
if link.source_ieee == link.target_ieee or key in seen:
|
||||
await db.delete(link)
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
|
||||
async def merge_devices(
|
||||
db: AsyncSession,
|
||||
winner: InventoryDevice,
|
||||
losers: list[InventoryDevice],
|
||||
) -> dict[str, Any]:
|
||||
"""Fold ``losers`` into ``winner``. Does not commit — the caller owns that.
|
||||
|
||||
The survivor keeps every fact it already has; a loser only fills a gap. That
|
||||
rule is the whole safety of the operation: whichever row the user (or the
|
||||
reconcile pass) picked, nothing it recorded is overwritten by an older or
|
||||
less-curated copy, and nothing the extras recorded is lost — lists are
|
||||
unioned, addresses are unioned, sources accumulate.
|
||||
|
||||
Returns counts for the caller to report.
|
||||
"""
|
||||
losers = [row for row in losers if row.id != winner.id]
|
||||
if not losers:
|
||||
return {
|
||||
"merged": 0,
|
||||
"device_id": winner.id,
|
||||
"nodes_repointed": 0,
|
||||
"documents_orphaned": 0,
|
||||
"views_extended": 0,
|
||||
}
|
||||
|
||||
loser_ids = [row.id for row in losers]
|
||||
# Oldest first: where two losers both fill the same gap, the older sighting
|
||||
# is the one that has survived longest without being contradicted.
|
||||
for loser in sorted(losers, key=lambda d: (d.discovered_at, d.id)):
|
||||
for field in _FILL_SCALARS:
|
||||
if _blank(getattr(winner, field, None)):
|
||||
value = getattr(loser, field, None)
|
||||
if not _blank(value):
|
||||
setattr(winner, field, value)
|
||||
winner.ip = _merged_ip(winner.ip, loser.ip)
|
||||
winner.mac = winner.mac or loser.mac
|
||||
winner.services = merge_services(winner.services, loser.services)
|
||||
winner.properties = merge_properties(winner.properties, loser.properties)
|
||||
winner.show_hardware = winner.show_hardware or loser.show_hardware
|
||||
for source in add_source(loser.discovery_sources, loser.discovery_source):
|
||||
winner.discovery_sources = add_source(winner.discovery_sources, source)
|
||||
if winner.status_live in (None, "", "unknown"):
|
||||
winner.status_live = loser.status_live
|
||||
winner.last_seen = _newest(winner.last_seen, loser.last_seen)
|
||||
winner.last_scan = _newest(winner.last_scan, loser.last_scan)
|
||||
# The device has been known since the earliest of the rows saw it.
|
||||
if loser.discovered_at and winner.discovered_at:
|
||||
winner.discovered_at = min(winner.discovered_at, loser.discovered_at)
|
||||
|
||||
winner.status = _merged_status(winner.status, losers)
|
||||
|
||||
nodes = await db.execute(
|
||||
update(Node).where(Node.device_id.in_(loser_ids)).values(device_id=winner.id)
|
||||
)
|
||||
await _refresh_rack_mounts(db, winner, losers)
|
||||
orphaned = await _merge_documents(db, winner, loser_ids)
|
||||
await db.flush()
|
||||
shown = await _show_merged_facts(db, winner)
|
||||
|
||||
loser_ieees = [row.ieee_address for row in losers if row.ieee_address]
|
||||
adopted: str | None = None
|
||||
if _blank(winner.ieee_address) and loser_ieees:
|
||||
# `ieee_address` is UNIQUE, so the address can only move once the row
|
||||
# holding it is gone — assigned after the delete below.
|
||||
adopted = loser_ieees[0]
|
||||
|
||||
for loser in losers:
|
||||
await db.delete(loser)
|
||||
await db.flush()
|
||||
|
||||
if adopted is not None:
|
||||
winner.ieee_address = adopted
|
||||
await db.flush()
|
||||
if winner.ieee_address:
|
||||
await _rewrite_links(db, winner.ieee_address, loser_ieees)
|
||||
|
||||
# Two nodes on one canvas may now draw the survivor — that is exactly the
|
||||
# same-design duplicate the node repair collapses.
|
||||
await dedupe_nodes_by_device(db)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"Inventory merge: %d row(s) folded into %s (%s)",
|
||||
len(losers), winner.id, winner.label or winner.friendly_name or winner.ip,
|
||||
)
|
||||
return {
|
||||
"merged": len(losers),
|
||||
"device_id": winner.id,
|
||||
"nodes_repointed": nodes.rowcount or 0,
|
||||
"documents_orphaned": orphaned,
|
||||
"views_extended": shown,
|
||||
}
|
||||
|
||||
|
||||
def _conflicting(left: InventoryDevice, right: InventoryDevice) -> bool:
|
||||
"""True when two rows carry addresses that say they are different machines.
|
||||
|
||||
A distinct non-blank IEEE on each is decisive: two Proxmox guests (the
|
||||
synthetic ``pve-{host}-{vmid}``) or two radios are never one device. A
|
||||
distinct MAC says the same for hardware. Either one vetoes an automatic
|
||||
merge — the user can still merge them by hand.
|
||||
"""
|
||||
if left.ieee_address and right.ieee_address and left.ieee_address.lower() != right.ieee_address.lower():
|
||||
return True
|
||||
return bool(left.mac and right.mac and left.mac != right.mac)
|
||||
|
||||
|
||||
def _pick_winner(group: list[InventoryDevice]) -> InventoryDevice:
|
||||
"""The row the others fold into.
|
||||
|
||||
An IEEE first: mesh links and the Proxmox host→guest graph are keyed on it,
|
||||
and the import matches on it before anything else, so the row holding one is
|
||||
the row every other writer will come back to. Then an approved row (it is
|
||||
the one already drawn), then the oldest.
|
||||
"""
|
||||
return sorted(
|
||||
group,
|
||||
key=lambda d: (
|
||||
0 if d.ieee_address else 1,
|
||||
0 if d.status == "approved" else 1,
|
||||
d.discovered_at,
|
||||
d.id,
|
||||
),
|
||||
)[0]
|
||||
|
||||
|
||||
def _auto_groups(rows: list[InventoryDevice]) -> list[list[InventoryDevice]]:
|
||||
"""Group rows a shared MAC proves to be one device.
|
||||
|
||||
The MAC and nothing else. A shared IP is not enough on its own — a re-used
|
||||
DHCP lease, two guests behind one NAT, or a container bridge address several
|
||||
guests report all make one address describe several machines — and the
|
||||
scanner already collapses what an IP does justify, under its own guard for
|
||||
rows that are drawn on a canvas. Labels and hostnames are ignored outright:
|
||||
two boxes are often called the same thing, and an automatic pass is not the
|
||||
place to decide they are one. Whatever is left over is what the manual merge
|
||||
is for.
|
||||
|
||||
A group is dropped whole when any pair inside it contradicts (see
|
||||
:func:`_conflicting`) rather than guessing which member does not belong.
|
||||
"""
|
||||
by_mac: dict[str, list[InventoryDevice]] = {}
|
||||
for row in rows:
|
||||
if row.mac:
|
||||
by_mac.setdefault(row.mac.lower(), []).append(row)
|
||||
|
||||
out = []
|
||||
for group in by_mac.values():
|
||||
if len(group) < 2:
|
||||
continue
|
||||
pairs = [(a, b) for i, a in enumerate(group) for b in group[i + 1:]]
|
||||
if any(_conflicting(a, b) for a, b in pairs):
|
||||
logger.info(
|
||||
"Inventory reconcile: leaving %d row(s) sharing a MAC alone — "
|
||||
"their IEEE says they are different devices (%s)",
|
||||
len(group), ", ".join(row.id for row in group),
|
||||
)
|
||||
continue
|
||||
out.append(group)
|
||||
return out
|
||||
|
||||
|
||||
async def reconcile_duplicates(db: AsyncSession) -> int:
|
||||
"""Merge inventory rows a shared MAC proves to be one device. Does not commit.
|
||||
|
||||
Meant to run at the end of an import, where a row that was created blind may
|
||||
have just gained the address that identifies it. Returns the number of rows
|
||||
merged away.
|
||||
|
||||
Hidden rows are excluded on both sides: the user hid one deliberately, and
|
||||
folding it into a visible row would put it back in front of them, while
|
||||
folding a visible row into it would make a device they are using disappear.
|
||||
"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(InventoryDevice)
|
||||
.where(InventoryDevice.status != "hidden")
|
||||
.order_by(InventoryDevice.discovered_at, InventoryDevice.id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
merged = 0
|
||||
for group in _auto_groups(list(rows)):
|
||||
winner = _pick_winner(group)
|
||||
result = await merge_devices(db, winner, [row for row in group if row is not winner])
|
||||
merged += int(result["merged"])
|
||||
return merged
|
||||
@@ -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()
|
||||
@@ -15,10 +15,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Edge, Node
|
||||
from app.db.models import Document, Edge, Node, RackDevice
|
||||
from app.services.inventory_sync import find_device_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -143,6 +143,29 @@ async def dedupe_nodes_by_device(db: AsyncSession) -> int:
|
||||
continue
|
||||
seen_pairs.add(key)
|
||||
|
||||
# A rack mount reads live status through `node_id`, and a document may
|
||||
# describe the node itself. Both are `SET NULL` in the schema and SQLite
|
||||
# runs with foreign keys off here, so a duplicate deleted from under them
|
||||
# would leave the column naming a node that no longer exists.
|
||||
await db.execute(
|
||||
update(RackDevice).where(RackDevice.node_id.in_(dup_ids)).values(node_id=canonical.id)
|
||||
)
|
||||
own_doc = (
|
||||
await db.execute(select(Document.id).where(Document.node_id == canonical.id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
doc_rows = (
|
||||
await db.execute(
|
||||
select(Document)
|
||||
.where(Document.node_id.in_(dup_ids))
|
||||
.order_by(Document.created_at, Document.id)
|
||||
)
|
||||
).scalars().all()
|
||||
for i, doc in enumerate(doc_rows):
|
||||
# `documents.node_id` is unique (partial index): only one document
|
||||
# can describe the survivor. The rest are orphaned, not deleted —
|
||||
# what the user wrote outlives the node it was attached to.
|
||||
doc.node_id = canonical.id if i == 0 and own_doc is None else None
|
||||
|
||||
await db.flush()
|
||||
for dup in dups:
|
||||
await db.delete(dup)
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import InventoryDevice, ScanRun
|
||||
from app.services.device_merge import merge_devices, reconcile_duplicates
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
||||
from app.services.http_probe import probe_open_ports
|
||||
@@ -643,10 +644,12 @@ async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
|
||||
for group in _group_by_shared_ip(list(rows)):
|
||||
if len(group) < 2:
|
||||
continue
|
||||
_, dups = _collapse_targets(group)
|
||||
for dup in dups:
|
||||
await db.delete(dup)
|
||||
deleted += 1
|
||||
keep, dups = _collapse_targets(group)
|
||||
if dups:
|
||||
# Non-destructive, for the same reason as `process_host`: whatever
|
||||
# drew the duplicate follows it into `keep`.
|
||||
await merge_devices(db, keep, dups)
|
||||
deleted += len(dups)
|
||||
if deleted:
|
||||
await db.commit()
|
||||
return deleted
|
||||
@@ -720,8 +723,12 @@ async def process_host(
|
||||
# by earlier scans — except a second approved row, which keeps its
|
||||
# own canvas links (see `_collapse_targets`).
|
||||
keep, dups = _collapse_targets(existing_rows)
|
||||
for dup in dups:
|
||||
await db.delete(dup)
|
||||
# Merge rather than delete: a duplicate row may be the one a canvas node
|
||||
# or a rack mount points at, and SQLite runs with foreign keys off here
|
||||
# so the declared `ON DELETE SET NULL` never fires. `merge_devices`
|
||||
# re-points every reference onto `keep` and unions the facts first.
|
||||
if dups:
|
||||
await merge_devices(db, keep, dups)
|
||||
keep.ip = keep.ip or ip # fill an IP a Proxmox import lacked
|
||||
keep.mac = norm_mac or keep.mac
|
||||
keep.hostname = host.get("hostname") or keep.hostname
|
||||
@@ -857,6 +864,14 @@ async def run_scan(
|
||||
else:
|
||||
mdns_task.cancel()
|
||||
|
||||
# A scan is where a row first learns its MAC, so it is also where two
|
||||
# rows can first be proven to be one device — fold those together before
|
||||
# reporting the run done.
|
||||
merged = await reconcile_duplicates(db)
|
||||
if merged:
|
||||
await db.commit()
|
||||
logger.info("Scan %s: merged %d duplicate inventory row(s)", run_id, merged)
|
||||
|
||||
# Mark scan as done or cancelled
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
|
||||
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
import app.db.database as database
|
||||
import app.services.inventory_sync as inventory_sync
|
||||
from app.core.config import APP_VERSION
|
||||
|
||||
# `create_all` under v3.2.0. NOT NULL is reproduced exactly where it was.
|
||||
_NODES_320 = (
|
||||
@@ -412,8 +413,10 @@ async def test_the_newest_backup_that_still_has_the_columns_is_the_one_read(db_3
|
||||
shutil.copy2(db_path, pre_split)
|
||||
|
||||
await database.init_db() # drops the columns, and backs up under this version
|
||||
# Post-split backups: newer, and unable to say who drew what.
|
||||
for name in ("back-3.3.1", "back-3.3.2"):
|
||||
# Post-split backups: newer, and unable to say who drew what. On a real
|
||||
# install the current version's own backup is one of them — the columns went
|
||||
# in an earlier boot, not this one — so overwrite the one `init_db` took.
|
||||
for name in ("back-3.3.1", "back-3.3.2", f"back-{APP_VERSION}"):
|
||||
shutil.copy2(db_path, db_path.parent / f"{db_path.name}.{name}")
|
||||
|
||||
assert database._pre_split_backup() == pre_split
|
||||
|
||||
@@ -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,405 @@
|
||||
"""Tests for the inventory duplicate repair (app.services.device_merge).
|
||||
|
||||
Two shapes, one merge: the user picks the survivor by hand, or an import proves
|
||||
two rows are one device by their shared MAC. What both must guarantee is that
|
||||
nothing is lost — not a fact, not a canvas node, not a rack mount, not a
|
||||
document, and not the *visibility* of the facts that just arrived on a canvas
|
||||
that never saw them.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import (
|
||||
Design,
|
||||
Document,
|
||||
InventoryDevice,
|
||||
InventoryDeviceLink,
|
||||
Node,
|
||||
Rack,
|
||||
RackDevice,
|
||||
)
|
||||
from app.services.device_merge import merge_devices, reconcile_duplicates
|
||||
|
||||
|
||||
def _at(day: int) -> datetime:
|
||||
return datetime(2026, 1, day, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
async def _design(db, name="d1"):
|
||||
design = Design(name=name)
|
||||
db.add(design)
|
||||
await db.flush()
|
||||
return design
|
||||
|
||||
|
||||
async def _device(db, **kwargs):
|
||||
kwargs.setdefault("discovered_at", _at(1))
|
||||
device = InventoryDevice(**kwargs)
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
return device
|
||||
|
||||
|
||||
async def _node(db, design, device, **kwargs):
|
||||
node = Node(design_id=design.id, device_id=device.id, label=kwargs.pop("label", "n8n"),
|
||||
type=kwargs.pop("type", "lxc"), **kwargs)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
return node
|
||||
|
||||
|
||||
async def _rack(db, design):
|
||||
rack = Rack(design_id=design.id, name="R1", u_height=42)
|
||||
db.add(rack)
|
||||
await db.flush()
|
||||
return rack
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_unions_facts_without_overwriting_the_survivor(db_session):
|
||||
winner = await _device(
|
||||
db_session, id="w", ip="192.168.1.62", mac="bc:24:11:95:af:8c",
|
||||
hostname="n8n", label="n8n", vendor="Proxmox VE", status="pending",
|
||||
services=[{"service_name": "ssh", "port": 22, "protocol": "tcp"}],
|
||||
properties=[{"key": "VMID", "value": "130", "visible": True}],
|
||||
discovery_source="arp", discovery_sources=["arp", "proxmox"],
|
||||
discovered_at=_at(5),
|
||||
)
|
||||
loser = await _device(
|
||||
db_session, id="l", ip="10.0.0.9", mac=None, hostname="ignored",
|
||||
notes="runs the automations", cpu_count=4, status="approved",
|
||||
services=[{"service_name": "http", "port": 5678, "protocol": "tcp"}],
|
||||
properties=[{"key": "Owner", "value": "me", "visible": True}],
|
||||
discovery_source="canvas", discovery_sources=["canvas"],
|
||||
discovered_at=_at(2),
|
||||
)
|
||||
|
||||
result = await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
|
||||
assert result["merged"] == 1
|
||||
assert await db_session.get(InventoryDevice, "l") is None
|
||||
# Established facts are never overwritten by the folded-in row...
|
||||
assert winner.hostname == "n8n"
|
||||
# ...but every gap it can fill, it fills.
|
||||
assert winner.notes == "runs the automations"
|
||||
assert winner.cpu_count == 4
|
||||
# Addresses and lists union; sources accumulate.
|
||||
assert winner.ip == "192.168.1.62, 10.0.0.9"
|
||||
assert {s["service_name"] for s in winner.services} == {"ssh", "http"}
|
||||
assert {p["key"] for p in winner.properties} == {"VMID", "Owner"}
|
||||
assert set(winner.discovery_sources) == {"arp", "proxmox", "canvas"}
|
||||
# Drawn on a canvas, so the survivor cannot go back to the pending queue.
|
||||
assert winner.status == "approved"
|
||||
# Known since the earliest of the two rows saw it.
|
||||
assert winner.discovered_at == _at(2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_repoints_canvas_nodes_and_rack_mounts(db_session):
|
||||
design = await _design(db_session)
|
||||
other = await _design(db_session, name="d2")
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62", status="approved")
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62", label="n8n", status="approved")
|
||||
|
||||
kept = await _node(db_session, design, winner, pos_x=10, pos_y=20)
|
||||
moved = await _node(db_session, other, loser, pos_x=300, pos_y=400)
|
||||
rack = await _rack(db_session, design)
|
||||
mount = RackDevice(design_id=design.id, rack_id=rack.id, device_id=loser.id, label="n8n")
|
||||
db_session.add(mount)
|
||||
await db_session.flush()
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
|
||||
# The node on the other canvas keeps its position and now draws the survivor.
|
||||
await db_session.refresh(moved)
|
||||
assert moved.device_id == "w"
|
||||
assert (moved.pos_x, moved.pos_y) == (300, 400)
|
||||
assert (await db_session.get(Node, kept.id)).device_id == "w"
|
||||
# The mount follows too — a rack that rendered before still renders.
|
||||
await db_session.refresh(mount)
|
||||
assert mount.device_id == "w"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_shows_the_new_facts_on_a_canvas_that_never_saw_them(db_session):
|
||||
"""The point of the merge: a poorer node must gain the richer row's facts.
|
||||
|
||||
`display_view` is a whitelist — without extending it the folded-in services
|
||||
and properties would render hidden and the canvas would look unchanged.
|
||||
"""
|
||||
design = await _design(db_session)
|
||||
winner = await _device(
|
||||
db_session, id="w", ip="192.168.1.62",
|
||||
services=[{"service_name": "ssh", "port": 22, "protocol": "tcp"}],
|
||||
properties=[{"key": "VMID", "value": "130", "visible": True}],
|
||||
)
|
||||
loser = await _device(
|
||||
db_session, id="l", ip="192.168.1.62",
|
||||
services=[{"service_name": "http", "port": 5678, "protocol": "tcp"}],
|
||||
properties=[],
|
||||
)
|
||||
poor = await _node(db_session, design, loser)
|
||||
poor.display_view = {
|
||||
"services": [{"key": "5678|tcp|http", "visible": True}],
|
||||
"properties": [],
|
||||
}
|
||||
await db_session.flush()
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
await db_session.refresh(poor)
|
||||
|
||||
services = poor.display_view["services"]
|
||||
assert [e["visible"] for e in services] == [True, True]
|
||||
assert {e["key"] for e in services} == {"5678|tcp|http", "22|tcp|ssh"}
|
||||
# A property list this canvas had never seen picks up the survivor's.
|
||||
assert [e["key"] for e in poor.display_view["properties"]] == ["vmid"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_keeps_a_deliberately_hidden_fact_hidden(db_session):
|
||||
design = await _design(db_session)
|
||||
winner = await _device(
|
||||
db_session, id="w", ip="192.168.1.62",
|
||||
services=[{"service_name": "ssh", "port": 22, "protocol": "tcp"}],
|
||||
)
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62", services=[])
|
||||
node = await _node(db_session, design, loser)
|
||||
node.display_view = {"services": [{"key": "22|tcp|ssh", "visible": False}], "properties": []}
|
||||
await db_session.flush()
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
await db_session.refresh(node)
|
||||
|
||||
assert node.display_view["services"] == [{"key": "22|tcp|ssh", "visible": False}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_collapses_two_nodes_that_now_draw_one_device(db_session):
|
||||
"""Both rows drawn on the *same* canvas: one node survives, the oldest."""
|
||||
design = await _design(db_session)
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62", status="approved")
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62", status="approved")
|
||||
keep = await _node(db_session, design, winner, pos_x=10, pos_y=20)
|
||||
await _node(db_session, design, loser, pos_x=99, pos_y=99)
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.design_id == design.id))
|
||||
).scalars().all()
|
||||
assert [n.id for n in nodes] == [keep.id]
|
||||
assert (nodes[0].pos_x, nodes[0].pos_y) == (10, 20)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rack_mount_survives_the_node_collapse(db_session):
|
||||
"""`rack_devices.node_id` must not be left naming a deleted duplicate node."""
|
||||
design = await _design(db_session)
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62", status="approved")
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62", status="approved")
|
||||
keep = await _node(db_session, design, winner)
|
||||
dup = await _node(db_session, design, loser)
|
||||
rack = await _rack(db_session, design)
|
||||
mount = RackDevice(
|
||||
design_id=design.id, rack_id=rack.id, device_id=loser.id, node_id=dup.id, label="n8n"
|
||||
)
|
||||
db_session.add(mount)
|
||||
await db_session.flush()
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
await db_session.refresh(mount)
|
||||
|
||||
assert mount.device_id == "w"
|
||||
assert mount.node_id == keep.id
|
||||
assert await db_session.get(Node, dup.id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_adopts_a_document_or_orphans_it(db_session):
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62")
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62")
|
||||
doc = Document(title="n8n", slug="n8n", device_id=loser.id, body="# n8n")
|
||||
db_session.add(doc)
|
||||
await db_session.flush()
|
||||
|
||||
result = await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
await db_session.refresh(doc)
|
||||
|
||||
# No document on the survivor, so the loser's is adopted rather than orphaned.
|
||||
assert doc.device_id == "w"
|
||||
assert result["documents_orphaned"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_second_document_is_orphaned_not_deleted(db_session):
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62")
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62")
|
||||
mine = Document(title="n8n", slug="n8n", device_id=winner.id, body="kept")
|
||||
theirs = Document(title="n8n (dup)", slug="n8n-dup", device_id=loser.id, body="written by hand")
|
||||
db_session.add_all([mine, theirs])
|
||||
await db_session.flush()
|
||||
|
||||
result = await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
await db_session.refresh(theirs)
|
||||
|
||||
assert result["documents_orphaned"] == 1
|
||||
assert theirs.device_id is None
|
||||
assert theirs.body == "written by hand" # survives as an orphan
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_moves_mesh_links_onto_the_survivor_ieee(db_session):
|
||||
winner = await _device(db_session, id="w", ieee_address="pve-pve1-130", ip="192.168.1.62")
|
||||
loser = await _device(db_session, id="l", ieee_address="0xAABB", ip="192.168.1.62")
|
||||
db_session.add(InventoryDeviceLink(source_ieee="pve-host", target_ieee="0xAABB"))
|
||||
db_session.add(InventoryDeviceLink(source_ieee="0xAABB", target_ieee="0xCCDD"))
|
||||
await db_session.flush()
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
|
||||
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
|
||||
pairs = {(link.source_ieee, link.target_ieee) for link in links}
|
||||
assert pairs == {("pve-host", "pve-pve1-130"), ("pve-pve1-130", "0xCCDD")}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_survivor_adopts_an_ieee_it_lacks(db_session):
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62", ieee_address=None)
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62", ieee_address="pve-pve1-130")
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
|
||||
assert winner.ieee_address == "pve-pve1-130"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_merges_rows_sharing_a_mac(db_session):
|
||||
"""The reported case: a Proxmox row and a canvas row, same NIC."""
|
||||
scanned = await _device(
|
||||
db_session, id="scanned", ip="192.168.1.62", mac="bc:24:11:95:af:8c",
|
||||
ieee_address="pve-proxmox-ai-130", status="pending", discovered_at=_at(1),
|
||||
)
|
||||
from_canvas = await _device(
|
||||
db_session, id="canvas", ip="192.168.1.62", mac="bc:24:11:95:af:8c",
|
||||
label="n8n", status="approved", discovered_at=_at(20),
|
||||
)
|
||||
|
||||
merged = await reconcile_duplicates(db_session)
|
||||
await db_session.flush()
|
||||
|
||||
assert merged == 1
|
||||
# The IEEE-bearing row wins: the Proxmox import and the link graph key on it.
|
||||
assert await db_session.get(InventoryDevice, from_canvas.id) is None
|
||||
assert (await db_session.get(InventoryDevice, scanned.id)).label == "n8n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_leaves_rows_that_only_share_an_ip(db_session):
|
||||
"""An IP is not an identity — a re-used lease must not merge two machines."""
|
||||
await _device(db_session, id="a", ip="192.168.1.62", mac="aa:aa:aa:aa:aa:aa")
|
||||
await _device(db_session, id="b", ip="192.168.1.62", mac="bb:bb:bb:bb:bb:bb")
|
||||
|
||||
assert await reconcile_duplicates(db_session) == 0
|
||||
assert len((await db_session.execute(select(InventoryDevice))).scalars().all()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_leaves_two_proxmox_guests_sharing_a_mac(db_session):
|
||||
"""Distinct synthetic IEEEs are decisive: two guests are never one device."""
|
||||
await _device(db_session, id="a", mac="bc:24:11:95:af:8c", ieee_address="pve-pve1-130")
|
||||
await _device(db_session, id="b", mac="bc:24:11:95:af:8c", ieee_address="pve-pve1-131")
|
||||
|
||||
assert await reconcile_duplicates(db_session) == 0
|
||||
assert len((await db_session.execute(select(InventoryDevice))).scalars().all()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_never_touches_a_hidden_row(db_session):
|
||||
"""A hidden row was hidden on purpose — merging it would resurrect it."""
|
||||
await _device(db_session, id="visible", mac="bc:24:11:95:af:8c", status="pending")
|
||||
await _device(db_session, id="hidden", mac="bc:24:11:95:af:8c", status="hidden")
|
||||
|
||||
assert await reconcile_duplicates(db_session) == 0
|
||||
hidden = await db_session.get(InventoryDevice, "hidden")
|
||||
assert hidden is not None and hidden.status == "hidden"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_is_idempotent(db_session):
|
||||
await _device(db_session, id="a", mac="bc:24:11:95:af:8c", ip="192.168.1.62")
|
||||
await _device(db_session, id="b", mac="bc:24:11:95:af:8c", ip="192.168.1.62")
|
||||
|
||||
assert await reconcile_duplicates(db_session) == 1
|
||||
await db_session.flush()
|
||||
assert await reconcile_duplicates(db_session) == 0
|
||||
|
||||
|
||||
# --- The route -----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_route_returns_the_survivor(client, headers, db_session):
|
||||
await _device(db_session, id="w", ip="192.168.1.62", label="n8n", status="approved")
|
||||
await _device(db_session, id="l", hostname="n8n", notes="the good notes")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/merge",
|
||||
json={"winner_id": "w", "loser_ids": ["l"]},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["id"] == "w"
|
||||
assert body["notes"] == "the good notes"
|
||||
assert await db_session.get(InventoryDevice, "l") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_route_rejects_a_single_device(client, headers, db_session):
|
||||
await _device(db_session, id="w", ip="192.168.1.62")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/merge",
|
||||
json={"winner_id": "w", "loser_ids": ["w"]},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_route_404s_on_an_unknown_row(client, headers, db_session):
|
||||
await _device(db_session, id="w", ip="192.168.1.62")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/merge",
|
||||
json={"winner_id": "w", "loser_ids": ["nope"]},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_route_requires_auth(client, db_session):
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/merge", json={"winner_id": "w", "loser_ids": ["l"]}
|
||||
)
|
||||
assert res.status_code in (401, 403)
|
||||
@@ -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
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "3.4.0",
|
||||
"version": "3.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "3.4.0",
|
||||
"version": "3.4.1",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
@@ -7194,9 +7194,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "3.4.0",
|
||||
"version": "3.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+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}
|
||||
|
||||
@@ -212,6 +212,14 @@ export const scanApi = {
|
||||
skipped: number
|
||||
skipped_devices: SkippedDevice[]
|
||||
}>('/scan/pending/bulk-approve', { device_ids: ids, design_id: designId ?? undefined }),
|
||||
/**
|
||||
* Fold several inventory rows into one, keeping `winnerId`. Returns the
|
||||
* survivor. The losers' facts, canvas nodes, rack mounts and documents move
|
||||
* onto it first — the identity match only runs when a row is created, so two
|
||||
* rows for one host never converge on their own.
|
||||
*/
|
||||
merge: (winnerId: string, loserIds: string[]) =>
|
||||
api.post<InventoryEntry>('/scan/pending/merge', { winner_id: winnerId, loser_ids: loserIds }),
|
||||
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
||||
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
|
||||
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
|
||||
@@ -350,6 +358,9 @@ export const documentsApi = {
|
||||
params: { block, device_id: deviceId },
|
||||
}),
|
||||
coverage: () => api.get<import('@/documentation/types').DocCoverage>('/documents/coverage'),
|
||||
// A zip of `.md` files mirroring the tree. Fetched through axios rather than
|
||||
// a plain link so the Bearer header rides along.
|
||||
export: () => api.get<Blob>('/documents/export', { responseType: 'blob' }),
|
||||
scaffold: (data: { device_ids?: string[]; only_with_notes?: boolean }) =>
|
||||
api.post<{ created: import('@/documentation/types').DocumentSummary[]; skipped: number }>(
|
||||
'/documents/scaffold',
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getCenteredPosition } from '@/utils/viewportCenter'
|
||||
import { sourceBuckets, orderedSources, isRackDevice, SOURCE_META, type SourceBucket } from '@/utils/deviceSources'
|
||||
import { isRackable } from '@/utils/rackable'
|
||||
import { ProxmoxApproveModal, type ProxmoxApproveChoice } from '@/components/modals/ProxmoxApproveModal'
|
||||
import { MergeDevicesModal } from '@/components/modals/MergeDevicesModal'
|
||||
import { layoutProxmoxContainers, measureProxmoxContainers } from '@/utils/proxmoxContainerLayout'
|
||||
|
||||
interface DeviceInventoryModalProps {
|
||||
@@ -184,6 +185,9 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
// Set when a Proxmox host with guests is about to be placed — the user picks
|
||||
// whether the guests come along, and nested or linked.
|
||||
const [proxmoxPrompt, setProxmoxPrompt] = useState<{ host: InventoryEntry; children: InventoryEntry[] } | null>(null)
|
||||
// Set when the user asks to merge the selected rows — they pick the survivor.
|
||||
const [mergePrompt, setMergePrompt] = useState<InventoryEntry[] | null>(null)
|
||||
const [merging, setMerging] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Tour/demo mode: show injected devices, never touch the backend.
|
||||
@@ -589,6 +593,27 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
}
|
||||
}
|
||||
|
||||
const handleMerge = async (winnerId: string) => {
|
||||
const ids = (mergePrompt ?? []).map((d) => d.id).filter((id) => id !== winnerId)
|
||||
if (ids.length === 0) return
|
||||
setMerging(true)
|
||||
try {
|
||||
await scanApi.merge(winnerId, ids)
|
||||
setMergePrompt(null)
|
||||
setSelectedIds(new Set())
|
||||
// Reload rather than patch: a merge moves canvas links around, so
|
||||
// `canvas_count` on the survivor is only right coming from the server.
|
||||
await load()
|
||||
toast.success(`Merged ${ids.length + 1} devices into one`, {
|
||||
description: 'Facts, canvas nodes, rack mounts and documents were kept.',
|
||||
})
|
||||
} catch {
|
||||
toast.error('Failed to merge devices')
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkHide = async () => {
|
||||
const ids = [...selectedIds]
|
||||
if (ids.length === 0) return
|
||||
@@ -851,6 +876,14 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
Clear
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setMergePrompt(devices.filter((d) => selectedIds.has(d.id)))}
|
||||
disabled={selectedIds.size < 2}
|
||||
title="Fold the selected rows into one device"
|
||||
className="text-xs px-3 py-1.5 rounded bg-[#00d4ff]/20 text-[#00d4ff] hover:bg-[#00d4ff]/30 disabled:opacity-40 font-medium transition-colors"
|
||||
>
|
||||
Merge ({selectedIds.size})
|
||||
</button>
|
||||
{statusFilter === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
@@ -957,6 +990,14 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
}}
|
||||
/>
|
||||
|
||||
<MergeDevicesModal
|
||||
open={mergePrompt !== null}
|
||||
devices={mergePrompt ?? []}
|
||||
busy={merging}
|
||||
onCancel={() => setMergePrompt(null)}
|
||||
onConfirm={handleMerge}
|
||||
/>
|
||||
|
||||
<ProxmoxApproveModal
|
||||
open={proxmoxPrompt !== null}
|
||||
host={proxmoxPrompt?.host ?? null}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react'
|
||||
import { Merge, Layers } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { InventoryEntry } from '@/types'
|
||||
import { deviceName, factCount, suggestWinner } from '@/utils/mergeWinner'
|
||||
|
||||
const ACCENT = '#00d4ff'
|
||||
|
||||
interface MergeDevicesModalProps {
|
||||
open: boolean
|
||||
/** The rows the user selected in the inventory. Two or more. */
|
||||
devices: InventoryEntry[]
|
||||
onCancel: () => void
|
||||
onConfirm: (winnerId: string) => void
|
||||
/** True while the request is in flight — the dialog stays up, disabled. */
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Asked before several Device Inventory rows are folded into one.
|
||||
*
|
||||
* The inventory only matches devices when a row is created, so two rows for one
|
||||
* host — minted before either carried the address that would have matched them
|
||||
* — never converge on their own. This is where the user says they are the same
|
||||
* machine and picks which row survives.
|
||||
*
|
||||
* Nothing is thrown away by the merge itself: the survivor keeps every fact it
|
||||
* has and fills its gaps from the others, and canvas nodes, rack mounts and
|
||||
* documents are re-pointed at it. The one thing the user must choose is which
|
||||
* id (and so which of two *conflicting* values) stands.
|
||||
*/
|
||||
export function MergeDevicesModal({ open, devices, onCancel, onConfirm, busy = false }: MergeDevicesModalProps) {
|
||||
// Only the user's override is state. The winner is otherwise derived, so a
|
||||
// choice held over from a previous selection — a row that no longer exists —
|
||||
// falls back to the suggestion instead of pointing at nothing.
|
||||
const [picked, setPicked] = useState<string | undefined>(undefined)
|
||||
|
||||
if (devices.length < 2) return null
|
||||
|
||||
const winnerId = devices.some((d) => d.id === picked) ? picked : suggestWinner(devices)
|
||||
const winner = devices.find((d) => d.id === winnerId)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onCancel()}>
|
||||
<DialogContent className="bg-[#161b22] border-border max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||
<Merge size={16} style={{ color: ACCENT }} />
|
||||
Merge {devices.length} devices into one
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keep which row? The others fold into it — their facts fill its gaps, and every
|
||||
canvas node, rack mount and document they own moves across.
|
||||
</p>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto space-y-1.5">
|
||||
{devices.map((d) => (
|
||||
<label
|
||||
key={d.id}
|
||||
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-xs cursor-pointer transition-colors ${
|
||||
d.id === winnerId
|
||||
? 'border-[#00d4ff]/50 bg-[#00d4ff]/10'
|
||||
: 'border-border bg-[#0d1117]/60 hover:border-muted-foreground/40'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="merge-winner"
|
||||
checked={d.id === winnerId}
|
||||
onChange={() => setPicked(d.id)}
|
||||
className="mt-0.5 cursor-pointer shrink-0"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-foreground font-medium truncate">{deviceName(d)}</span>
|
||||
{(d.canvas_count ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-[#00d4ff] shrink-0">
|
||||
<Layers size={10} />
|
||||
{d.canvas_count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground font-mono break-all">
|
||||
{[d.ip, d.mac, d.ieee_address].filter(Boolean).join(' · ') || 'no address'}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
{factCount(d)} fact{factCount(d) !== 1 ? 's' : ''}
|
||||
{d.services?.length ? ` · ${d.services.length} service${d.services.length !== 1 ? 's' : ''}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{winner
|
||||
? `Merging cannot be undone. ${devices.length - 1} row${devices.length - 1 !== 1 ? 's' : ''} will be deleted once their facts and links are on ${deviceName(winner)}.`
|
||||
: 'Pick the row to keep.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => winnerId && onConfirm(winnerId)}
|
||||
disabled={!winnerId || busy}
|
||||
style={{ backgroundColor: `${ACCENT}33`, color: ACCENT }}
|
||||
>
|
||||
{busy ? 'Merging…' : `Merge into ${winner ? deviceName(winner) : 'this row'}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const mockHidden = vi.fn()
|
||||
const mockAddNode = vi.fn()
|
||||
const mockSetSelectedNode = vi.fn()
|
||||
const mockProxmoxChildren = vi.fn()
|
||||
const mockMerge = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
@@ -30,6 +31,7 @@ vi.mock('@/api/client', () => ({
|
||||
restore: (...a: unknown[]) => mockRestore(...a),
|
||||
bulkRestore: (...a: unknown[]) => mockBulkRestore(...a),
|
||||
proxmoxChildren: (...a: unknown[]) => mockProxmoxChildren(...a),
|
||||
merge: (...a: unknown[]) => mockMerge(...a),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -497,6 +499,29 @@ describe('DeviceInventoryModal', () => {
|
||||
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a']))
|
||||
})
|
||||
|
||||
it('merge needs two rows, then folds them into the chosen survivor', async () => {
|
||||
mockMerge.mockResolvedValue({ data: { ...DEVICE_IP } })
|
||||
render(<DeviceInventoryModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
|
||||
// One row is not a duplicate of anything — the action stays disabled.
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
expect(screen.getByRole('button', { name: /Merge \(1\)/ })).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Merge \(2\)/ }))
|
||||
|
||||
// The dialog asks which row survives before anything is sent.
|
||||
expect(mockMerge).not.toHaveBeenCalled()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^merge into/i }))
|
||||
await waitFor(() => expect(mockMerge).toHaveBeenCalledTimes(1))
|
||||
const [winner, losers] = mockMerge.mock.calls[0]
|
||||
expect([winner, ...losers].sort()).toEqual(['dev-a', 'dev-b'])
|
||||
// Reloaded, so the survivor's canvas count comes from the server.
|
||||
await waitFor(() => expect(mockPending).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('does not load when closed', () => {
|
||||
render(<DeviceInventoryModal {...baseProps} open={false} />)
|
||||
expect(mockPending).not.toHaveBeenCalled()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { MergeDevicesModal } from '../MergeDevicesModal'
|
||||
import type { InventoryEntry } from '@/types'
|
||||
|
||||
const devices = [
|
||||
{
|
||||
id: 'proxmox', label: 'n8n', ip: '192.168.1.62', mac: 'bc:24:11:95:af:8c',
|
||||
ieee_address: 'pve-proxmox-ai-130', status: 'pending', services: [],
|
||||
discovered_at: '2026-04-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'canvas', label: 'n8n', ip: '192.168.1.62', mac: 'bc:24:11:95:af:8c',
|
||||
status: 'approved', canvas_count: 1, services: [],
|
||||
discovered_at: '2026-08-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'stub', label: 'n8n', ip: null, mac: null, status: 'pending', services: [],
|
||||
discovered_at: '2026-08-10T00:00:00Z',
|
||||
},
|
||||
] as unknown as InventoryEntry[]
|
||||
|
||||
const props = {
|
||||
open: true,
|
||||
devices,
|
||||
onCancel: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
}
|
||||
|
||||
describe('MergeDevicesModal', () => {
|
||||
beforeEach(() => {
|
||||
props.onCancel.mockReset()
|
||||
props.onConfirm.mockReset()
|
||||
})
|
||||
|
||||
it('renders nothing for a selection that cannot be merged', () => {
|
||||
const { container } = render(<MergeDevicesModal {...props} devices={[devices[0]]} />)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('lists every selected row with its addresses', () => {
|
||||
render(<MergeDevicesModal {...props} />)
|
||||
expect(screen.getByText(/merge 3 devices into one/i)).toBeDefined()
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(3)
|
||||
expect(screen.getByText(/pve-proxmox-ai-130/)).toBeDefined()
|
||||
// The row with no address says so rather than rendering an empty line.
|
||||
expect(screen.getByText('no address')).toBeDefined()
|
||||
})
|
||||
|
||||
it('preselects the suggested survivor and merges into it', () => {
|
||||
render(<MergeDevicesModal {...props} />)
|
||||
const radios = screen.getAllByRole('radio') as HTMLInputElement[]
|
||||
expect(radios[0].checked).toBe(true) // the IEEE-bearing row
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^merge into/i }))
|
||||
expect(props.onConfirm).toHaveBeenCalledWith('proxmox')
|
||||
})
|
||||
|
||||
it('lets the user keep a different row instead', () => {
|
||||
render(<MergeDevicesModal {...props} />)
|
||||
fireEvent.click(screen.getAllByRole('radio')[1])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^merge into/i }))
|
||||
expect(props.onConfirm).toHaveBeenCalledWith('canvas')
|
||||
})
|
||||
|
||||
it('warns how many rows go, and that it cannot be undone', () => {
|
||||
render(<MergeDevicesModal {...props} />)
|
||||
expect(screen.getByText(/2 rows will be deleted/i)).toBeDefined()
|
||||
expect(screen.getByText(/cannot be undone/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables both actions while the merge is in flight', () => {
|
||||
render(<MergeDevicesModal {...props} busy />)
|
||||
expect(screen.getByRole('button', { name: /merging/i })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toHaveProperty('disabled', true)
|
||||
expect(props.onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels without merging', () => {
|
||||
render(<MergeDevicesModal {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||
expect(props.onCancel).toHaveBeenCalled()
|
||||
expect(props.onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -251,6 +251,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
collapsed={collapsed}
|
||||
active={view === 'documentation'}
|
||||
onClick={() => setView('documentation')}
|
||||
dataTour="documentation"
|
||||
/>
|
||||
)}
|
||||
{/* The hidden devices are a filter of this same modal, reached from
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { deviceName, factCount, suggestWinner } from '@/utils/mergeWinner'
|
||||
import type { InventoryEntry } from '@/types'
|
||||
|
||||
function entry(over: Partial<InventoryEntry>): InventoryEntry {
|
||||
return {
|
||||
id: 'x', ip: null, mac: null, hostname: null, os: null, services: [],
|
||||
suggested_type: null, status: 'pending', discovered_at: '2026-01-01T00:00:00Z',
|
||||
...over,
|
||||
} as InventoryEntry
|
||||
}
|
||||
|
||||
describe('suggestWinner', () => {
|
||||
it('prefers the row carrying an IEEE — imports and links key on it', () => {
|
||||
const devices = [
|
||||
entry({ id: 'canvas', canvas_count: 1, discovered_at: '2025-01-01T00:00:00Z' }),
|
||||
entry({ id: 'proxmox', ieee_address: 'pve-pve1-130' }),
|
||||
]
|
||||
expect(suggestWinner(devices)).toBe('proxmox')
|
||||
})
|
||||
|
||||
it('falls back to a row that is already drawn on a canvas', () => {
|
||||
const devices = [
|
||||
entry({ id: 'stub' }),
|
||||
entry({ id: 'drawn', canvas_count: 2, discovered_at: '2026-06-01T00:00:00Z' }),
|
||||
]
|
||||
expect(suggestWinner(devices)).toBe('drawn')
|
||||
})
|
||||
|
||||
it('falls back to the oldest row when nothing else separates them', () => {
|
||||
const devices = [
|
||||
entry({ id: 'young', discovered_at: '2026-06-01T00:00:00Z' }),
|
||||
entry({ id: 'old', discovered_at: '2025-02-01T00:00:00Z' }),
|
||||
]
|
||||
expect(suggestWinner(devices)).toBe('old')
|
||||
})
|
||||
|
||||
it('is undefined for an empty selection', () => {
|
||||
expect(suggestWinner([])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('factCount', () => {
|
||||
it('counts the scalars, services and properties a row knows', () => {
|
||||
const rich = entry({
|
||||
ip: '192.168.1.62', mac: 'bc:24:11:95:af:8c', hostname: 'n8n',
|
||||
services: [{ port: 22, protocol: 'tcp', service_name: 'ssh' }] as InventoryEntry['services'],
|
||||
properties: [{ key: 'VMID', value: '130' }] as InventoryEntry['properties'],
|
||||
})
|
||||
expect(factCount(rich)).toBe(5)
|
||||
expect(factCount(entry({}))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deviceName', () => {
|
||||
it('prefers the name the user gave the device', () => {
|
||||
expect(deviceName(entry({ label: 'n8n', hostname: 'ct130', ip: '10.0.0.1' }))).toBe('n8n')
|
||||
expect(deviceName(entry({ hostname: 'ct130', ip: '10.0.0.1' }))).toBe('ct130')
|
||||
expect(deviceName(entry({}))).toBe('device')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { InventoryEntry } from '@/types'
|
||||
|
||||
/** The name the inventory prints for a device — the one the user gave it first. */
|
||||
export function deviceName(d: InventoryEntry): string {
|
||||
return d.label ?? d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'
|
||||
}
|
||||
|
||||
/**
|
||||
* The row the others should fold into, by default.
|
||||
*
|
||||
* Mirrors the backend's own precedence (`device_merge._pick_winner`): an IEEE
|
||||
* first — the Proxmox import and the mesh link graph key on it, so that row is
|
||||
* where every other writer comes back — then a row that is already drawn on a
|
||||
* canvas, then the oldest. Only a suggestion: the user picks in the end.
|
||||
*/
|
||||
export function suggestWinner(devices: InventoryEntry[]): string | undefined {
|
||||
return [...devices].sort((a, b) => (
|
||||
(a.ieee_address ? 0 : 1) - (b.ieee_address ? 0 : 1)
|
||||
|| ((b.canvas_count ?? 0) > 0 ? 1 : 0) - ((a.canvas_count ?? 0) > 0 ? 1 : 0)
|
||||
|| a.discovered_at.localeCompare(b.discovered_at)
|
||||
|| a.id.localeCompare(b.id)
|
||||
))[0]?.id
|
||||
}
|
||||
|
||||
/** How much a row knows — shown so the user can tell the rich one from the stub. */
|
||||
export function factCount(d: InventoryEntry): number {
|
||||
const scalars = [d.ip, d.mac, d.hostname, d.os, d.vendor, d.model, d.notes, d.ieee_address]
|
||||
return scalars.filter(Boolean).length + (d.services?.length ?? 0) + (d.properties?.length ?? 0)
|
||||
}
|
||||
@@ -17,6 +17,31 @@ describe('getSteps', () => {
|
||||
expect(ids).not.toContain('scan-history')
|
||||
expect(ids).not.toContain('inventory')
|
||||
expect(ids).not.toContain('imports')
|
||||
// Documentation has nowhere to store a document without the backend.
|
||||
expect(ids).not.toContain('docs')
|
||||
expect(ids).not.toContain('docs-write')
|
||||
expect(ids).not.toContain('docs-devices')
|
||||
})
|
||||
|
||||
it('walks the Documentation section on anchors the view carries', () => {
|
||||
const docs = STEPS.filter((s) => s.id.startsWith('docs'))
|
||||
expect(docs.map((s) => s.id)).toEqual(['docs', 'docs-write', 'docs-devices'])
|
||||
expect(docs.map((s) => s.anchor)).toEqual([
|
||||
'[data-tour="documentation"]',
|
||||
'[data-tour="docs-new"]',
|
||||
'[data-tour="docs-devices"]',
|
||||
])
|
||||
// Every one of them needs the section open: `closeAll` puts the view back
|
||||
// to the canvas before each step, so the action cannot be dropped from the
|
||||
// second and third.
|
||||
expect(docs.every((s) => s.action === 'openDocumentation')).toBe(true)
|
||||
expect(docs.every((s) => s.mode === 'full')).toBe(true)
|
||||
})
|
||||
|
||||
it('documents the homelab once it has been mapped, before the styling step', () => {
|
||||
const ids = STEPS.map((s) => s.id)
|
||||
expect(ids.indexOf('docs')).toBeGreaterThan(ids.indexOf('rack'))
|
||||
expect(ids.indexOf('docs-devices')).toBeLessThan(ids.indexOf('style'))
|
||||
})
|
||||
|
||||
it('keeps the rack step in both modes — a rack canvas needs no backend', () => {
|
||||
|
||||
@@ -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