Compare commits

...
32 Commits
Author SHA1 Message Date
Pouzor f07f43686e chore: bump version to 3.1.2
ha-relevant: no
2026-08-01 01:26:23 +02:00
Pouzor 3bde741fd2 chore(deps): clear the seven open dependabot alerts
Lockfile only — every advisory is transitive and no direct dependency range
had to move:

- postcss 8.5.14 -> 8.5.25 (high, GHSA-r28c-9q8g-f849, path traversal in
  source-map auto-loading), pulled in by vite and shadcn
- fast-uri 3.1.2 -> 3.1.5 (two high advisories), via shadcn's ajv
- hono 4.12.25 -> 4.12.33 and @hono/node-server 1.19.14 -> 2.0.12 (three
  moderate advisories), via shadcn's @modelcontextprotocol/sdk, itself bumped
  1.29.0 -> 1.30.0

`npm audit` now reports 0 vulnerabilities. Build, lint and the full frontend
suite (1542 tests) pass unchanged.

ha-relevant: no
2026-08-01 01:14:21 +02:00
NebeszandPouzor - Rémy Jardient e8ea6857bc feat(mcp): add lightweight node lookup tools
Add a label substring filter to GET /api/v1/nodes, plus MCP tools
list_node_summaries (id/label/type/status only) and get_node
(by id -> single object, by label -> array) so an LLM client can look
up nodes without paying the token cost of the full node payload.
2026-07-30 01:57:34 +02:00
Pouzor - Rémy JardientandGitHub 42774fc138 Merge pull request #311 from Nebesz/fix/mcp-node-type-enum
fix: sync MCP node type enum with frontend NodeType
2026-07-30 01:37:33 +02:00
Pouzor 8f273e0201 fix: sync MCP edge type enum with frontend and gate both enums in CI
The node type fix left the same stale-enum bug in place for edges:
create_edge only advertised ethernet/wifi/iot/vlan/virtual, while the
frontend EdgeType union also defines cluster, fibre and electrical.
The backend's EdgeBase.type is a plain str, so those three were always
accepted server-side — only the MCP schema rejected them.

- Extract EDGE_TYPES in mcp/app/tools.py and use it for create_edge.
- Add mcp/tests/test_edge_types_sync.py, mirroring the NodeType guard:
  parses EdgeType out of frontend/src/types/index.ts and asserts set
  equality with EDGE_TYPES.
- Add create_edge cases for the three previously-rejected types plus a
  schema test pinning the enum contents and the ethernet default.
- Add an `mcp` job to quality.yml. Both sync guards were unreachable
  from CI before: quality.yml only ran frontend and backend, so the
  enums could drift again with nothing to catch it. The job runs from a
  full checkout because the guards read the frontend sources, which are
  not in the mcp Docker build context.
- Pin mcp[cli]>=1.26,<2. The bound is load-bearing: SDK 2.0 dropped the
  low-level Server.list_resources/list_tools decorator API that
  app/resources.py and app/tools.py are built on, so an unbounded spec
  breaks both the tests and the Docker image at import time.
- Move pytest/pytest-anyio/trio into mcp/requirements.txt, matching how
  backend/requirements.txt carries its own test deps. trio is required
  because pytest-anyio parametrises every async test over both backends.

Tested: 114 passed in mcp/, both in the existing venv and in a clean
Python 3.13 venv installed the way the new CI job does.

ha-relevant: no
2026-07-30 01:05:48 +02:00
Pouzor - Rémy JardientandGitHub 3c8e463dd9 Merge pull request #313 from Pouzor/feat/service-brand-icons
feat: brand icon picker for services, in a dedicated add/edit modal
2026-07-28 22:12:27 +02:00
Pouzor c4017c7634 feat(services): brand icon picker and dedicated add/edit modal
Services can now carry a brand logo (homarr dashboard-icons) or a generic
lucide icon, the same way nodes already could — closes the request in
discussion #299 where services stand in for docker images.

The add/edit form moves out of the right-hand DetailPanel into a proper
ServiceModal, which gives the icon picker room to breathe and matches how
nodes are edited.

- ServiceModal: name, port, protocol, path, icon; validates the name and
  clamps the port, and spreads over the original service on save so fields
  it doesn't own (category) survive an edit
- IconPickerPanel: Generic/Brand picker extracted from NodeModal so both
  surfaces share one implementation
- ServiceIcon: renders a lucide or brand:<slug> icon in the DetailPanel
  badge and on the canvas node, and nothing when the key can't be resolved
- Legacy scanner signature keys (bar-chart-2) still don't resolve to an
  icon, but the modal shows them verbatim and preserves them on save

ServiceInfo.icon already existed and the backend stores services as
list[Any], so no schema or migration change.

ha-relevant: yes
2026-07-28 22:00:45 +02:00
Nebesz a88688170a fix: sync MCP node type enum with frontend NodeType
The hardcoded NODE_TYPES list in mcp/app/tools.py only had 11 entries,
causing create_node/update_node/approve_device to reject valid device
types (firewall, docker_container, ups, etc.) that the backend and
frontend already support. Expanded the list to match
frontend/src/types/index.ts's NodeType union, excluding group/groupRect/text
which are canvas annotations, not devices. Added a test that parses the
frontend union and fails if the two lists drift apart again.
2026-07-28 18:17:52 +02:00
Pouzor - Rémy JardientandGitHub aaccb50b00 Merge pull request #306 from autogpt-elendir/feat/oidc-auth
feat(auth): add generic OIDC browser sessions
2026-07-28 00:29:41 +02:00
Pouzor 34758c9850 fix(ui): use official Homelable logo on login page
The login page rendered lucide's generic Network glyph instead of the
brand mark. Use the Logo component there, and realign Logo with the
canonical assets in docs/logo:

- match icon.svg geometry exactly (bg glow gradient, node glow filter)
- fix inverted wordmark colors (Home #e6edf3 / lable #00d4ff)
- scope gradient/filter ids per instance so the Sidebar and Toolbar
  logos don't share defs ids

ha-relevant: maybe
2026-07-25 00:02:39 +02:00
ypso 4d2284dc37 fix(auth): harden OIDC callback validation 2026-07-24 11:12:57 +02:00
Pouzor 9f974f2fcf docs(auth): document OIDC setup and configuration
- FEATURES.md: add "Authentication (Local / OpenID Connect)" section + TOC
- docs/oidc-auth.md: flow overview, env var reference, fail-closed validation
  rules, provider setup (Authentik/Keycloak/Authelia/Google), reverse-proxy
  notes, session/logout caveats, troubleshooting, revert-to-local

ha-relevant: no
2026-07-21 23:35:50 +02:00
Pouzor 37ad9c6933 fix(ci): scope dependency-audit npm audit to runtime deps
The dependency-audit job failed on vulnerabilities from `shadcn` and its
transitive tree (hono, fast-uri, body-parser, @modelcontextprotocol/sdk).
`shadcn` is a build-time scaffolding CLI plus a CSS `@import`, never shipped
in the browser bundle, so it belongs in devDependencies.

- move `shadcn` to devDependencies (regenerated lockfile: dev flags only,
  no version changes)
- run `npm audit` with `--omit=dev`, matching the runtime-only intent of the
  pip-audit step

npm audit --omit=dev --audit-level=high now reports 0 vulnerabilities; the
production build still resolves the shadcn CSS import.

ha-relevant: no
2026-07-21 23:34:34 +02:00
ypso 99e7b9a343 ci(security): enforce unsuppressed Python audit 2026-07-21 00:37:32 +02:00
ypso f061db645b chore(deps): refresh vulnerable frontend packages 2026-07-21 00:36:06 +02:00
ypso fd96b7d6ae feat(auth): add OIDC browser session flow 2026-07-21 00:36:02 +02:00
ypso 0e95d99868 feat(auth): add generic OIDC backend sessions 2026-07-21 00:35:57 +02:00
Pouzor - Rémy JardientandGitHub d9f3b4c18a Merge pull request #303 from Pouzor/release/3.1.1
chore: bump version to 3.1.1
2026-07-19 22:23:57 +02:00
Pouzor 80ebbca804 chore: bump version to 3.1.1 2026-07-19 22:17:59 +02:00
Pouzor - Rémy JardientandGitHub 959cd4a3ae Merge pull request #302 from Isilla/feat/services-drag-reorder
feat(detail-panel): make services list draggable to reorder
2026-07-19 22:07:20 +02:00
Marianne JOSEPH 084a8e23e7 feat(detail-panel): make services list draggable to reorder
Mirrors the existing properties drag-and-drop (native HTML5 drag)
so services can be reordered the same way.
2026-07-19 21:59:16 +02:00
Pouzor - Rémy JardientandGitHub f5dd46a729 Merge pull request #301 from Pouzor/fix/yaml-export-port-numbers
fix: round-trip Show Port Numbers on YAML export/import
2026-07-19 21:12:18 +02:00
Pouzor 121873f41c test(settings): wait for async config before asserting Save payload
The Proxmox/Zigbee auto-sync Save tests clicked Save after only
waiting for an unrelated field, so under CI load getConfig could
resolve after the click and Save fired with the default
sync_enabled=false. Wait for the toggle to reflect the loaded
state first.
2026-07-19 15:34:19 +02:00
Pouzor 16d6348cec fix(yaml): round-trip show_port_numbers on export/import
YAML export wrote per-side handle counts but never the
show_port_numbers toggle, so the option was lost on re-import
while connection points survived.

ha-relevant: maybe

Fixes #272
2026-07-19 15:15:13 +02:00
Pouzor - Rémy JardientandGitHub 28fc4d8a7f Merge pull request #300 from Pouzor/feat/getting-started-walkthrough
feat(walkthrough): Getting Started tour + new-user canvas detection
2026-07-19 11:52:27 +02:00
Pouzor 5726289b18 fix(toaster): enable richColors so error surface actually applies
Setting --error-bg alone had no effect: sonner only honors per-type
surfaces when richColors is enabled, so error toasts kept using
--normal-bg and looked identical to a normal toast. Enable richColors
and pin success/info/warning back to the neutral popover surface so only
errors turn red.
2026-07-19 11:44:47 +02:00
Pouzor e85dab8cfc fix(toaster): render error toasts on a red surface
Error toasts previously used the same neutral popover background as
success toasts, so a failure (e.g. a save while the backend is down)
read like a normal 'Canvas saved'. Set sonner's per-type --error-bg/
--error-text/--error-border so errors stand out in red.
2026-07-19 11:33:54 +02:00
Pouzor deccff15cf changing wording 2026-07-19 11:05:43 +02:00
Pouzor a1bf54c417 fix(walkthrough): keep sidebar crisp behind modal steps
Neutralize the app modal backdrop's dim/blur while the tour runs so a
ringed sidebar/toolbar control behind an open modal (steps 2, 10) stays
readable — the overlay's SVG spotlight is now the single source of
dimming. Add a walkthrough-running body class toggled by the overlay and
a CSS rule stripping [data-slot=dialog-overlay] background/backdrop-filter.

Also refine coach-card placement: dialogCardPosition drops the card into
the empty bottom-right corner for large modals (inventory, scan history)
and beside small modals, so it never covers modal content (steps 3, 4).
2026-07-19 01:32:08 +02:00
Pouzor 8c56921375 feat(walkthrough): add Getting Started tour with first-run invite
Bespoke spotlight walkthrough (no new deps) that guides new users through the
main features. A version-stamped invite bubble offers the tour to new users and
re-offers it once to existing users after an upgrade; restartable from Settings.

- Multi-hole SVG-mask overlay keeps a ringed control and any open modal lit
- Steps: scan, scan history, inventory, nodes, edit, text/zone, grouping,
  style, imports, and a closing recap with a GitHub link
- Scan/history/inventory/import steps inject demo data (no backend calls) and
  are filtered out in standalone, which ends on a full-mode recap
- Persisted invite state via localStorage (utils/walkthrough.ts), live tour
  state via zustand (stores/walkthroughStore.ts)

ha-relevant: maybe
2026-07-19 00:40:08 +02:00
Pouzor bf8551015c feat(canvas): distinguish new user from cleared canvas; stop demo on backend error
Cleared canvas re-showed the demo, and backend errors silently fell back to
demo — hiding real outages and forcing users to wipe demo nodes before bulk
edits. Now:
- backend load reports `initialized` (CanvasState row exists = ever saved)
- decideCanvasLoad() picks real | empty | demo; empty is kept empty
- backend down/error shows an error banner + toast, never the demo
- data-new-user flag reserved as the Getting Started walkthrough hook

ha-relevant: yes
2026-07-18 23:02:44 +02:00
Pouzor 3f030959be docs(readme): rework header with logo, badges, nav links 2026-07-18 20:06:22 +02:00
85 changed files with 4750 additions and 507 deletions
+20 -2
View File
@@ -1,4 +1,5 @@
# Backend - server-side only (NEVER commit .env)
# Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change_me_in_production
SQLITE_PATH=./data/homelab.db
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
@@ -7,13 +8,30 @@ SQLITE_PATH=./data/homelab.db
# Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Auth — default credentials: admin / admin
# Auth — local mode is the backward-compatible default.
AUTH_MODE=local
# Local credentials: admin / admin by default.
# ⚠️ Change before exposing on a network.
# Generate a new hash: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
# Generate a new hash: python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
# ⚠️ Keep the single quotes around the hash — bcrypt hashes contain \$ which Docker misinterprets without them.
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
# OpenID Connect (optional alternative to local auth).
# Set AUTH_MODE=oidc and configure every value below. The callback must be
# registered exactly at the identity provider. Keep CORS_ORIGINS restricted to
# the Homelable browser origin; wildcard CORS is rejected in OIDC mode. OIDC
# also requires SECRET_KEY to contain at least 32 bytes.
# AUTH_MODE=oidc
# OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
# OIDC_CLIENT_ID=homelable
# OIDC_CLIENT_SECRET=replace-with-a-secret
# OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
# OIDC_SCOPES="openid profile email"
# OIDC_COOKIE_SECURE=true
# OIDC_SESSION_EXPIRE_MINUTES=480
# OIDC_TRANSACTION_EXPIRE_SECONDS=600
# Scanner — JSON array of CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
+17
View File
@@ -60,3 +60,20 @@ jobs:
- run: ruff check .
- run: mypy app/
- run: pytest
# Also guards the NODE_TYPES / EDGE_TYPES enums in mcp/app/tools.py against
# drift from frontend/src/types/index.ts (test_node_types_sync.py,
# test_edge_types_sync.py) — those tests need the frontend sources, so this
# job runs from a full checkout rather than the mcp Docker context.
mcp:
runs-on: ubuntu-latest
defaults:
run:
working-directory: mcp
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: pip install -r requirements.txt
- run: pytest
+5 -4
View File
@@ -31,11 +31,12 @@ jobs:
with:
node-version: '20'
- name: NPM Audit
run: cd frontend && npm audit --audit-level=high
# --omit=dev: only ship-time deps gate the build. Dev-only tooling
# (shadcn CLI and its transitive tree: hono, fast-uri, …) is never
# bundled or served, so its advisories must not fail release CI.
run: cd frontend && npm audit --omit=dev --audit-level=high
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Pip audit
# CVE-2024-23342 (ecdsa): timing attack on ECDSA — not exploitable (app uses HS256/HMAC only).
# python-jose forces ecdsa as a dependency with no fixed version available.
run: pip install pip-audit && pip-audit -r backend/requirements.txt --ignore-vuln CVE-2024-23342
run: pip install pip-audit && pip-audit -r backend/requirements.txt
+35
View File
@@ -5,6 +5,41 @@ 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.1.2] - 2026-08-01
### Features
- Generic OIDC authentication: backend sessions and browser login flow. Thanks @autogpt-elendir. (#306)
- Brand icon picker for services, in a dedicated add/edit modal. (#313)
- MCP: lightweight node lookup tools. Thanks @Nebesz. (#312)
### Fixes
- Sync the MCP node and edge type enums with the frontend, gated in CI against drift. Thanks @Nebesz. (#311)
- Harden OIDC callback validation. (#306)
- Use the official Homelable logo on the login page. (#306)
- Clear the seven open dependabot alerts and refresh vulnerable frontend packages. (#317, #306)
### Docs
- Document OIDC setup and configuration. (#306)
## [3.1.1] - 2026-07-19
### Features
- Drag to reorder services in the node detail panel. Thanks @Isilla. (#302)
- Getting Started walkthrough tour with first-run canvas detection. (#300)
### Fixes
- Round-trip Show Port Numbers on YAML export/import. (#301)
- Render error toasts on a red surface. (#300)
### Docs
- Rework README header with logo, badges, and nav links. (#300)
## [3.1.0] - 2026-07-18
### Features
+18
View File
@@ -29,6 +29,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
15. [Gethomepage Widget](#15-gethomepage-widget-)
16. [MCP Server (AI integration)](#16-mcp-server-)
17. [Settings & Shortcuts](#17-settings--shortcuts)
18. [Authentication (Local / OpenID Connect)](#18-authentication-local--openid-connect-)
---
@@ -252,4 +253,21 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
---
## 18. Authentication (Local / OpenID Connect) 🔒
**What:** Homelable protects the app behind a login. Two exclusive modes, set once in `.env` with `AUTH_MODE`:
- **`local`** (default) — a single username + bcrypt-hashed password. Nothing changes for existing installs.
- **`oidc`** — sign in through your own identity provider (Authentik, Keycloak, Authelia, Google, …) via **Authorization Code + PKCE**. Good for SSO, per-user accounts at the IdP, MFA, and central account management.
**Use (OIDC):**
1. Register Homelable as a **confidential** client at your IdP and set the redirect URI to `https://<your-host>/api/v1/auth/oidc/callback`.
2. In `.env` set `AUTH_MODE=oidc` and the `OIDC_*` values (discovery URL, client id/secret, redirect URI, scopes), keep `CORS_ORIGINS` pinned to your browser origin, and give `SECRET_KEY` at least 32 bytes.
3. Restart the backend. The login screen now shows **Sign in with OpenID Connect**.
**How it protects you:** provider tokens are exchanged server-side and never touch the browser — Homelable issues its own short-lived, `__Host-`, HttpOnly, `SameSite=Lax` session cookie. Discovery metadata, issuer, audience, signature, expiry, nonce, state and PKCE are all validated; cookie-authenticated writes require a CSRF token and an allowed `Origin`; the status WebSocket authenticates from the cookie and validates `Origin`. Local Bearer auth and the MCP service key keep working unchanged. Full config, provider examples and troubleshooting: [docs/oidc-auth.md](./docs/oidc-auth.md).
> OIDC is Full-mode only — the no-backend standalone build has no login.
---
*Installing (Docker, Proxmox LXC, source) is covered in [INSTALLATION.md](./INSTALLATION.md). Running Home Assistant? See [homelable-hacs](https://github.com/Pouzor/homelable-hacs).*
+26 -2
View File
@@ -13,7 +13,7 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
>
Generate a new hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode())'
```
@@ -21,7 +21,7 @@ docker compose exec backend python -c "from passlib.context import CryptContext;
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode().replace("$", "$$"))'
```
## Quick Start — Frontend only
@@ -69,6 +69,7 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/Proxmo
All configuration is done via `.env` (copied from `.env.example`):
```env
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change_me_in_production
# Auth — default: admin / admin
@@ -82,6 +83,29 @@ SCANNER_RANGES=["192.168.1.0/24"]
STATUS_CHECKER_INTERVAL=60
```
### OpenID Connect (optional)
OIDC is an exclusive alternative to the local password. Existing installs stay
in `AUTH_MODE=local` unless explicitly changed.
```env
AUTH_MODE=oidc
CORS_ORIGINS=["https://homelable.example"]
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
OIDC_CLIENT_ID=homelable
OIDC_CLIENT_SECRET=replace-with-a-secret
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
OIDC_SCOPES="openid profile email"
OIDC_COOKIE_SECURE=true
OIDC_SESSION_EXPIRE_MINUTES=480
```
Register `OIDC_REDIRECT_URI` exactly at the identity provider. Production OIDC
requires HTTPS, a non-wildcard `CORS_ORIGINS`, and a confidential client. The
backend uses Authorization Code with PKCE and keeps provider tokens out of the
browser. `SECRET_KEY` must contain at least 32 bytes in OIDC mode. Do not expose
`OIDC_CLIENT_SECRET` in Compose YAML or commit it to Git.
All settings are also editable in-app via the **Scan Network** button.
---
+31 -2
View File
@@ -1,4 +1,34 @@
# Homelable
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" />&nbsp;Homelable</h1>
<p align="center">
<strong>Self-hosted homelab infrastructure visualization, scanning &amp; live monitoring</strong>
</p>
<p align="center">
<a href="https://github.com/Pouzor/homelable/releases/latest"><img src="https://img.shields.io/github/v/release/Pouzor/homelable" alt="Latest release" /></a>
<a href="https://github.com/Pouzor/homelable/actions/workflows/docker-ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Pouzor/homelable/docker-ci.yml?branch=main&amp;label=build" alt="Build status" /></a>
<a href="https://github.com/Pouzor/homelable/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="https://github.com/Pouzor/homelable/issues"><img src="https://img.shields.io/github/issues/Pouzor/homelable" alt="Issues" /></a>
<a href="https://github.com/Pouzor/homelable/stargazers"><img src="https://img.shields.io/github/stars/Pouzor/homelable?style=social" alt="Stars" /></a>
<a href="https://github.com/Pouzor/homelable/network/members"><img src="https://img.shields.io/github/forks/Pouzor/homelable?style=social" alt="Forks" /></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="130" /></a>
</p>
<p align="center">
<a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> ·
<a href="#installation">Installation</a> ·
<a href="#network-scanner">Network Scanner</a> ·
<a href="#zigbee2mqtt-import">Zigbee / Z-Wave</a> ·
<a href="#proxmox-ve-import">Proxmox</a> ·
<a href="#live-view-read-only-public-canvas">Live View</a> ·
<a href="#mcp-server-ai-integration-optional">MCP Server</a>
</p>
## 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.
@@ -10,7 +40,6 @@ If you just like the design, you can only run the frontend and export your desig
If you are running <img width="22" height="22" align="top" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="250" height="55"/></a>
---
## Screenshots
+1 -1
View File
@@ -1 +1 @@
3.1.0
3.1.2
+45 -10
View File
@@ -1,31 +1,66 @@
import hmac
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import settings
from app.core.security import decode_token
from app.core.security import decode_token, get_oidc_session
bearer = HTTPBearer(auto_error=False)
def get_current_user(
@dataclass(frozen=True)
class AuthContext:
subject: str
display_name: str
auth_method: str
issuer: str | None = None
csrf_token: str | None = None
def get_auth_context(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
x_mcp_service_key: str | None = Header(default=None),
) -> str:
) -> AuthContext:
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
if x_mcp_service_key is not None:
if not settings.mcp_service_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
return "__mcp_service__"
return AuthContext(
subject="__mcp_service__",
display_name="MCP service",
auth_method="mcp",
)
# 2. Standard JWT bearer token
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
username = decode_token(credentials.credentials)
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return username
if credentials is not None:
username = decode_token(credentials.credentials)
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return AuthContext(
subject=username,
display_name=username,
auth_method="local",
)
# 3. OIDC session cookie (only active in exclusive OIDC mode)
if settings.auth_mode == "oidc":
oidc_session = get_oidc_session(request)
if oidc_session is not None:
return AuthContext(
subject=oidc_session.subject,
display_name=oidc_session.display_name,
auth_method="oidc",
issuer=oidc_session.issuer,
csrf_token=oidc_session.csrf_token,
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
def get_current_user(context: AuthContext = Depends(get_auth_context)) -> str:
return context.subject
+97 -2
View File
@@ -1,12 +1,27 @@
import hmac
import logging
from collections.abc import Mapping
from typing import cast
from fastapi import APIRouter, HTTPException, status
from authlib.integrations.base_client.errors import OAuthError
from authlib.jose.errors import JoseError
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel
from starlette.responses import RedirectResponse
from app.api.deps import AuthContext, get_auth_context
from app.core.config import settings
from app.core.security import create_access_token, verify_password
from app.core.security import (
clear_oidc_session_cookie,
create_access_token,
create_oidc_session_token,
get_oidc_client,
set_oidc_session_cookie,
verify_password,
)
router = APIRouter()
logger = logging.getLogger(__name__)
class LoginRequest(BaseModel):
@@ -19,8 +34,36 @@ class TokenResponse(BaseModel):
token_type: str = "bearer"
class AuthConfigResponse(BaseModel):
mode: str
oidc_login_url: str | None = None
class CurrentUserResponse(BaseModel):
subject: str
display_name: str
auth_method: str
issuer: str | None = None
csrf_token: str | None = None
def _require_oidc_mode() -> None:
if settings.auth_mode != "oidc":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC login is disabled")
@router.get("/config", response_model=AuthConfigResponse)
async def auth_config() -> AuthConfigResponse:
return AuthConfigResponse(
mode=settings.auth_mode,
oidc_login_url="/api/v1/auth/oidc/login" if settings.auth_mode == "oidc" else None,
)
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest) -> TokenResponse:
if settings.auth_mode != "local":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local login is disabled")
# Always run both checks to prevent timing-based username enumeration.
# hmac.compare_digest is constant-time; verify_password (bcrypt) always runs.
username_ok = hmac.compare_digest(body.username, settings.auth_username)
@@ -29,3 +72,55 @@ async def login(body: LoginRequest) -> TokenResponse:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token(body.username)
return TokenResponse(access_token=token)
@router.get("/oidc/login")
async def oidc_login(request: Request) -> Response:
_require_oidc_mode()
client = get_oidc_client()
return cast(Response, await client.authorize_redirect(request, settings.oidc_redirect_uri))
@router.get("/oidc/callback")
async def oidc_callback(request: Request) -> Response:
_require_oidc_mode()
client = get_oidc_client()
try:
token = await client.authorize_access_token(request)
except (OAuthError, JoseError) as exc:
logger.warning("OIDC callback rejected: %s", exc.error)
request.session.clear()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
userinfo = token.get("userinfo")
if not isinstance(userinfo, Mapping):
request.session.clear()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed")
try:
session_token = create_oidc_session_token(userinfo)
except ValueError:
request.session.clear()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
request.session.clear()
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
set_oidc_session_cookie(response, session_token)
return response
@router.get("/me", response_model=CurrentUserResponse)
async def current_user(context: AuthContext = Depends(get_auth_context)) -> CurrentUserResponse:
return CurrentUserResponse(
subject=context.subject,
display_name=context.display_name,
auth_method=context.auth_method,
issuer=context.issuer,
csrf_token=context.csrf_token,
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(_: AuthContext = Depends(get_auth_context)) -> Response:
response = Response(status_code=status.HTTP_204_NO_CONTENT)
clear_oidc_session_cookie(response)
return response
+3
View File
@@ -37,6 +37,9 @@ async def load_canvas(
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=state.custom_style if state else None,
# A CanvasState row exists only after a save (or explicit design create),
# so its presence marks an intentional canvas vs. a never-touched one.
initialized=state is not None,
)
+10 -4
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -55,9 +55,15 @@ async def _find_free_position(db: AsyncSession, design_id: str | None) -> tuple[
@router.get("", response_model=list[NodeResponse])
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]:
result = await db.execute(select(Node))
return list(result.scalars().all())
async def list_nodes(
label: str | None = Query(None, description="Case-insensitive substring filter on node label"),
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> list[Node]:
query = select(Node)
if label:
query = query.where(Node.label.ilike(f"%{label}%"))
return list((await db.execute(query)).scalars().all())
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
+28 -12
View File
@@ -3,7 +3,13 @@ import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.core.security import decode_token
from app.core.config import settings
from app.core.security import (
decode_oidc_session_token,
decode_token,
oidc_session_cookie_name,
origin_is_allowed,
)
router = APIRouter()
@@ -21,19 +27,29 @@ def _drop(websocket: WebSocket) -> None:
async def ws_status(websocket: WebSocket) -> None:
# Accept first so we can send a close frame with a reason code
await websocket.accept()
try:
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
raw = await websocket.receive_text()
try:
payload = json.loads(raw)
token = payload.get("token", "")
except (json.JSONDecodeError, AttributeError):
token = ""
if not token or not decode_token(token):
if not origin_is_allowed(websocket.headers.get("origin")):
await websocket.close(code=1008) # Policy Violation
return
if settings.auth_mode == "oidc":
cookie_token = websocket.cookies.get(oidc_session_cookie_name(), "")
if not cookie_token or decode_oidc_session_token(cookie_token) is None:
await websocket.close(code=1008) # Policy Violation
return
except WebSocketDisconnect:
return
else:
# Local mode keeps the existing first-message bearer protocol.
try:
raw = await websocket.receive_text()
try:
payload = json.loads(raw)
token = payload.get("token", "")
except (json.JSONDecodeError, AttributeError):
token = ""
if not token or not decode_token(token):
await websocket.close(code=1008) # Policy Violation
return
except WebSocketDisconnect:
return
_connections.append(websocket)
try:
+43 -2
View File
@@ -1,8 +1,9 @@
import json
import logging
from pathlib import Path
from typing import Literal
from pydantic import model_validator
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
@@ -35,11 +36,22 @@ class Settings(BaseSettings):
access_token_expire_minutes: int = 1440 # 24h
# Auth — set AUTH_USERNAME and AUTH_PASSWORD_HASH in .env
auth_mode: Literal["local", "oidc"] = "local"
auth_username: str = "admin"
auth_password_hash: str = ""
# OpenID Connect — required only when AUTH_MODE=oidc.
oidc_discovery_url: str = ""
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_redirect_uri: str = ""
oidc_scopes: str = "openid profile email"
oidc_cookie_secure: bool = True
oidc_session_expire_minutes: int = Field(default=480, ge=5, le=1440)
oidc_transaction_expire_seconds: int = Field(default=600, ge=60, le=3600)
@model_validator(mode="after")
def check_password_hash(self) -> "Settings":
def validate_auth_settings(self) -> "Settings":
h = self.auth_password_hash
if h and not h.startswith("$2"):
logger.error(
@@ -47,6 +59,35 @@ class Settings(BaseSettings):
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
)
if self.auth_mode == "oidc":
required = {
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
"OIDC_CLIENT_ID": self.oidc_client_id,
"OIDC_CLIENT_SECRET": self.oidc_client_secret,
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
}
missing = [name for name, value in required.items() if not value]
if missing:
raise ValueError(f"AUTH_MODE=oidc requires: {', '.join(missing)}")
if len(self.secret_key.encode("utf-8")) < 32:
raise ValueError("SECRET_KEY must be at least 32 bytes when AUTH_MODE=oidc")
if "openid" not in self.oidc_scopes.split():
raise ValueError("OIDC_SCOPES must contain 'openid'")
for name, value in {
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
}.items():
if not value.startswith(("https://", "http://")):
raise ValueError(f"{name} must be an HTTP(S) URL")
if self.oidc_cookie_secure:
for name, value in {
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
}.items():
if not value.startswith("https://"):
raise ValueError(f"{name} must use HTTPS when OIDC_COOKIE_SECURE=true")
if "*" in self.cors_origins:
raise ValueError("CORS_ORIGINS cannot contain '*' when AUTH_MODE=oidc")
return self
# Scanner
+181 -3
View File
@@ -1,10 +1,33 @@
import hmac
import secrets
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import Any
import bcrypt
from jose import JWTError, jwt
import jwt
from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
from fastapi import Request, Response, status
from jwt import InvalidTokenError
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse
from app.core.config import settings
ACCESS_TOKEN_USE = "access"
OIDC_SESSION_TOKEN_USE = "oidc_session"
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
@dataclass(frozen=True)
class OIDCSession:
subject: str
issuer: str
display_name: str
csrf_token: str
def verify_password(plain: str, hashed: str) -> bool:
if not plain or not hashed:
@@ -21,14 +44,169 @@ def hash_password(password: str) -> str:
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": subject, "exp": expire}
payload = {"sub": subject, "exp": expire, "token_use": ACCESS_TOKEN_USE}
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
def decode_token(token: str) -> str | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("token_use") not in (None, ACCESS_TOKEN_USE):
return None
sub = payload.get("sub")
return str(sub) if sub is not None else None
except JWTError:
except InvalidTokenError:
return None
def create_oidc_session_token(userinfo: Mapping[str, Any]) -> str:
subject = userinfo.get("sub")
issuer = userinfo.get("iss")
if not isinstance(subject, str) or not subject or not isinstance(issuer, str) or not issuer:
raise ValueError("OIDC userinfo must contain non-empty 'sub' and 'iss' claims")
display_name = next(
(
value
for value in (
userinfo.get("preferred_username"),
userinfo.get("name"),
userinfo.get("email"),
subject,
)
if isinstance(value, str) and value
),
subject,
)
now = datetime.now(timezone.utc)
payload = {
"sub": subject,
"oidc_issuer": issuer,
"name": display_name,
"csrf": secrets.token_urlsafe(32),
"iat": now,
"exp": now + timedelta(minutes=settings.oidc_session_expire_minutes),
"token_use": OIDC_SESSION_TOKEN_USE,
}
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
def decode_oidc_session_token(token: str) -> OIDCSession | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("token_use") != OIDC_SESSION_TOKEN_USE:
return None
subject = payload.get("sub")
issuer = payload.get("oidc_issuer")
display_name = payload.get("name")
csrf_token = payload.get("csrf")
if not isinstance(subject, str) or not subject:
return None
if not isinstance(issuer, str) or not issuer:
return None
if not isinstance(display_name, str) or not display_name:
return None
if not isinstance(csrf_token, str) or not csrf_token:
return None
return OIDCSession(
subject=subject,
issuer=issuer,
display_name=display_name,
csrf_token=csrf_token,
)
except InvalidTokenError:
return None
def oidc_session_cookie_name() -> str:
return "__Host-homelable-session" if settings.oidc_cookie_secure else "homelable-session"
def set_oidc_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=oidc_session_cookie_name(),
value=token,
max_age=settings.oidc_session_expire_minutes * 60,
secure=settings.oidc_cookie_secure,
httponly=True,
samesite="lax",
path="/",
)
def clear_oidc_session_cookie(response: Response) -> None:
response.delete_cookie(
key=oidc_session_cookie_name(),
secure=settings.oidc_cookie_secure,
httponly=True,
samesite="lax",
path="/",
)
def get_oidc_session(request: Request) -> OIDCSession | None:
token = request.cookies.get(oidc_session_cookie_name())
return decode_oidc_session_token(token) if token else None
def origin_is_allowed(origin: str | None) -> bool:
if not origin:
return False
allowed_origins = {value.rstrip("/") for value in settings.cors_origins}
return origin.rstrip("/") in allowed_origins
class OIDCCSRFMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if settings.auth_mode != "oidc" or request.method in SAFE_METHODS:
return await call_next(request)
# Explicit non-cookie credentials take precedence in get_current_user,
# so their requests keep the existing CSRF-free API behavior.
if request.headers.get("Authorization") or request.headers.get("X-MCP-Service-Key"):
return await call_next(request)
oidc_session = get_oidc_session(request)
if oidc_session is None:
return await call_next(request)
csrf_token = request.headers.get("X-Homelable-CSRF", "")
csrf_valid = hmac.compare_digest(csrf_token.encode(), oidc_session.csrf_token.encode())
if not csrf_valid or not origin_is_allowed(request.headers.get("Origin")):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "CSRF validation failed"},
)
return await call_next(request)
@lru_cache(maxsize=8)
def _build_oidc_client(
discovery_url: str,
client_id: str,
client_secret: str,
scopes: str,
) -> StarletteOAuth2App:
oauth = OAuth()
client = oauth.register(
name="oidc",
client_id=client_id,
client_secret=client_secret,
server_metadata_url=discovery_url,
client_kwargs={
"scope": scopes,
"code_challenge_method": "S256",
},
)
if client is None:
raise RuntimeError("Failed to register OIDC client")
return client
def get_oidc_client() -> StarletteOAuth2App:
return _build_oidc_client(
settings.oidc_discovery_url,
settings.oidc_client_id,
settings.oidc_client_secret,
settings.oidc_scopes,
)
+12 -1
View File
@@ -6,6 +6,7 @@ from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.api.routes import (
auth,
@@ -25,6 +26,7 @@ from app.api.routes import (
from app.api.routes import settings as settings_routes
from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler
from app.core.security import OIDCCSRFMiddleware
from app.db.database import init_db
@@ -53,12 +55,21 @@ app = FastAPI(
lifespan=lifespan,
)
app.add_middleware(OIDCCSRFMiddleware)
app.add_middleware(
SessionMiddleware,
secret_key=settings.secret_key,
session_cookie=("__Host-homelable-oidc-flow" if settings.oidc_cookie_secure else "homelable-oidc-flow"),
max_age=settings.oidc_transaction_expire_seconds,
same_site="lax",
https_only=settings.oidc_cookie_secure,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
allow_headers=["Authorization", "Content-Type", "X-Homelable-CSRF", "X-MCP-Service-Key"],
)
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
+5
View File
@@ -84,3 +84,8 @@ class CanvasStateResponse(BaseModel):
edges: list[EdgeResponse]
viewport: dict[str, Any]
custom_style: dict[str, Any] | None = None
# True once this design's canvas has ever been persisted (a CanvasState row
# exists). Lets the frontend tell a brand-new user (show demo) apart from one
# who intentionally cleared their canvas (keep it empty). False also for a
# missing/uninitialized design.
initialized: bool = False
+3 -1
View File
@@ -6,8 +6,10 @@ aiosqlite==0.20.0
alembic==1.13.3
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.5.0
PyJWT==2.13.0
bcrypt==4.2.1
authlib==1.7.2
itsdangerous==2.2.0
python-multipart==0.0.31
apscheduler==3.10.4
python-nmap==0.7.1
+288 -3
View File
@@ -1,5 +1,56 @@
import time
from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlparse
import pytest
from authlib.integrations.base_client.errors import OAuthError
from authlib.jose.errors import ExpiredTokenError, JoseError
from fastapi import WebSocketDisconnect
from httpx import AsyncClient
from starlette.responses import RedirectResponse
from starlette.testclient import TestClient
class FakeOIDCClient:
def __init__(self, token=None, error: OAuthError | JoseError | None = None):
self.token = token or {}
self.error = error
self.redirect_uri: str | None = None
async def authorize_redirect(self, _request, redirect_uri: str):
self.redirect_uri = redirect_uri
return RedirectResponse("https://idp.example/application/o/authorize/?state=test", status_code=302)
async def authorize_access_token(self, _request):
if self.error is not None:
raise self.error
return self.token
@pytest.fixture
def oidc_settings():
from app.core.config import settings
from app.core.security import _build_oidc_client
values = {
"auth_mode": "oidc",
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
"oidc_client_id": "homelable",
"oidc_client_secret": "test-client-secret",
"oidc_redirect_uri": "http://test/api/v1/auth/oidc/callback",
"oidc_scopes": "openid profile email",
"oidc_cookie_secure": False,
"oidc_session_expire_minutes": 60,
"cors_origins": ["http://test"],
}
original = {name: getattr(settings, name) for name in values}
for name, value in values.items():
setattr(settings, name, value)
_build_oidc_client.cache_clear()
yield settings
for name, value in original.items():
setattr(settings, name, value)
_build_oidc_client.cache_clear()
async def test_login_success(client: AsyncClient):
@@ -10,6 +61,12 @@ async def test_login_success(client: AsyncClient):
assert data["token_type"] == "bearer"
async def test_auth_config_defaults_to_local(client: AsyncClient):
res = await client.get("/api/v1/auth/config")
assert res.status_code == 200
assert res.json() == {"mode": "local", "oidc_login_url": None}
async def test_login_wrong_password(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 401
@@ -76,7 +133,7 @@ async def test_expired_token_rejected(client: AsyncClient):
"""A JWT whose `exp` is in the past must be refused."""
from datetime import datetime, timedelta, timezone
from jose import jwt
import jwt
from app.core.config import settings
payload = {
@@ -97,14 +154,14 @@ async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
"""A token signed with a different key must not be accepted."""
from datetime import datetime, timedelta, timezone
from jose import jwt
import jwt
from app.core.config import settings
payload = {
"sub": "admin",
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
}
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
forged = jwt.encode(payload, "different-secret-that-is-at-least-32-bytes", algorithm=settings.algorithm)
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
assert res.status_code == 401
@@ -136,3 +193,231 @@ def test_verify_password_handles_empty_inputs():
assert verify_password("", h) is False
assert verify_password("hunter2", "") is False
assert verify_password("", "") is False
# --- OpenID Connect ---
async def test_oidc_config_exposes_login_url(client: AsyncClient, oidc_settings):
res = await client.get("/api/v1/auth/config")
assert res.status_code == 200
assert res.json() == {
"mode": "oidc",
"oidc_login_url": "/api/v1/auth/oidc/login",
}
async def test_local_login_is_disabled_in_oidc_mode(client: AsyncClient, oidc_settings):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
assert res.status_code == 404
async def test_oidc_login_is_hidden_in_local_mode(client: AsyncClient):
res = await client.get("/api/v1/auth/oidc/login")
assert res.status_code == 404
async def test_oidc_login_uses_configured_callback(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient()
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/login")
assert res.status_code == 302
assert res.headers["location"].startswith("https://idp.example/")
assert fake_client.redirect_uri == "http://test/api/v1/auth/oidc/callback"
async def test_oidc_client_enables_pkce_s256_and_nonce(oidc_settings):
from app.core.security import get_oidc_client
oidc_client = get_oidc_client()
metadata = {"authorization_endpoint": "https://idp.example/application/o/authorize/"}
with patch.object(oidc_client, "load_server_metadata", AsyncMock(return_value=metadata)):
authorization = await oidc_client.create_authorization_url(oidc_settings.oidc_redirect_uri)
query = parse_qs(urlparse(authorization["url"]).query)
assert query["response_type"] == ["code"]
assert query["code_challenge_method"] == ["S256"]
assert "code_challenge" in query
assert "code_verifier" not in query
assert query["nonce"] == [authorization["nonce"]]
assert authorization["code_verifier"]
async def test_oidc_callback_creates_session_for_api(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(token={
"userinfo": {
"iss": "https://idp.example/application/o/homelable/",
"sub": "user-123",
"preferred_username": "alice",
},
})
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
callback = await client.get("/api/v1/auth/oidc/callback")
assert callback.status_code == 303
assert callback.headers["location"] == "/"
cookie = callback.headers["set-cookie"]
assert "homelable-session=" in cookie
assert "HttpOnly" in cookie
assert "SameSite=lax" in cookie
me = await client.get("/api/v1/auth/me")
assert me.status_code == 200
assert me.json()["subject"] == "user-123"
assert me.json()["display_name"] == "alice"
assert me.json()["auth_method"] == "oidc"
assert me.json()["issuer"] == "https://idp.example/application/o/homelable/"
assert me.json()["csrf_token"]
protected = await client.get("/api/v1/nodes")
assert protected.status_code == 200
@pytest.mark.parametrize("userinfo", [None, {}, {"sub": "user-123"}, {"iss": "https://idp.example/"}])
async def test_oidc_callback_rejects_missing_identity_claims(client: AsyncClient, oidc_settings, userinfo):
token = {} if userinfo is None else {"userinfo": userinfo}
fake_client = FakeOIDCClient(token=token)
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/callback")
assert res.status_code == 401
assert res.json() == {"detail": "OIDC authentication failed"}
async def test_oidc_callback_hides_protocol_error_details(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(error=OAuthError(error="invalid_grant", description="sensitive detail"))
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/callback")
assert res.status_code == 401
assert res.json() == {"detail": "OIDC authentication failed"}
assert "sensitive detail" not in res.text
async def test_oidc_callback_hides_claim_validation_error_details(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(error=ExpiredTokenError())
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/callback")
assert res.status_code == 401
assert res.json() == {"detail": "OIDC authentication failed"}
assert "expired" not in res.text.lower()
async def test_oidc_session_token_cannot_be_used_as_bearer(client: AsyncClient, oidc_settings):
from app.core.security import create_oidc_session_token
token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 401
async def test_oidc_cookie_mutations_require_csrf_and_allowed_origin(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(token={
"userinfo": {
"iss": "https://idp.example/application/o/homelable/",
"sub": "user-123",
"preferred_username": "alice",
},
})
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
await client.get("/api/v1/auth/oidc/callback")
csrf_token = (await client.get("/api/v1/auth/me")).json()["csrf_token"]
missing = await client.post("/api/v1/auth/logout")
assert missing.status_code == 403
wrong_origin = await client.post(
"/api/v1/auth/logout",
headers={"Origin": "https://evil.example", "X-Homelable-CSRF": csrf_token},
)
assert wrong_origin.status_code == 403
valid = await client.post(
"/api/v1/auth/logout",
headers={"Origin": "http://test", "X-Homelable-CSRF": csrf_token},
)
assert valid.status_code == 204
assert "Max-Age=0" in valid.headers["set-cookie"]
assert (await client.get("/api/v1/auth/me")).status_code == 401
async def test_local_bearer_logout_does_not_require_csrf(client: AsyncClient, headers):
res = await client.post("/api/v1/auth/logout", headers=headers)
assert res.status_code == 204
# --- WebSocket authentication ---
def _wait_for_connection_count(expected: int) -> None:
from app.api.routes import status as status_routes
deadline = time.monotonic() + 1
while len(status_routes._connections) != expected and time.monotonic() < deadline:
time.sleep(0.01)
assert len(status_routes._connections) == expected
def test_local_websocket_keeps_first_message_bearer_flow():
from app.core.security import create_access_token
from app.main import app
test_client = TestClient(app)
try:
with test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "http://localhost:3000"},
) as websocket:
websocket.send_json({"token": create_access_token("admin")})
_wait_for_connection_count(1)
_wait_for_connection_count(0)
finally:
test_client.close()
def test_websocket_rejects_untrusted_origin_before_auth():
from app.main import app
test_client = TestClient(app)
try:
with (
pytest.raises(WebSocketDisconnect) as caught,
test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "https://evil.example"},
) as websocket,
):
websocket.receive_text()
assert caught.value.code == 1008
finally:
test_client.close()
def test_oidc_websocket_uses_session_cookie_without_javascript_token(oidc_settings):
from app.core.security import create_oidc_session_token, oidc_session_cookie_name
from app.main import app
session_token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
test_client = TestClient(app)
test_client.cookies.set(oidc_session_cookie_name(), session_token)
try:
with test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "http://test"},
):
_wait_for_connection_count(1)
_wait_for_connection_count(0)
finally:
test_client.close()
def test_oidc_websocket_rejects_missing_session_cookie(oidc_settings):
from app.main import app
test_client = TestClient(app)
try:
with (
pytest.raises(WebSocketDisconnect) as caught,
test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "http://test"},
) as websocket,
):
websocket.receive_text()
assert caught.value.code == 1008
finally:
test_client.close()
+17
View File
@@ -22,6 +22,23 @@ async def test_load_canvas_empty(client: AsyncClient, headers: dict):
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict):
# A never-saved canvas has no CanvasState row → initialized False. The frontend
# uses this to show the demo canvas only to genuinely new users.
data = (await client.get("/api/v1/canvas", headers=headers)).json()
assert data["initialized"] is False
async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict):
# Saving an EMPTY canvas still creates a CanvasState row, so a subsequently
# loaded empty canvas is reported initialized — the user cleared it on purpose
# and must not get the demo re-seeded.
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
data = (await client.get("/api/v1/canvas", headers=headers)).json()
assert data["nodes"] == []
assert data["initialized"] is True
async def test_load_canvas_requires_auth(client: AsyncClient):
res = await client.get("/api/v1/canvas")
assert res.status_code == 401
+40
View File
@@ -363,3 +363,43 @@ async def test_create_node_explicit_zero_position_preserved(client: AsyncClient,
)
assert res.status_code == 201
assert (res.json()["pos_x"], res.json()["pos_y"]) == (0.0, 0.0)
async def test_list_nodes_filters_by_label_exact(client: AsyncClient, headers: dict):
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
res = await client.get("/api/v1/nodes?label=Proxmox", headers=headers)
assert res.status_code == 200
assert [n["label"] for n in res.json()] == ["Proxmox"]
async def test_list_nodes_filters_by_label_substring(client: AsyncClient, headers: dict):
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve1-node", "status": "unknown"}, headers=headers)
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve2-node", "status": "unknown"}, headers=headers)
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
res = await client.get("/api/v1/nodes?label=pve", headers=headers)
assert res.status_code == 200
assert sorted(n["label"] for n in res.json()) == ["pve1-node", "pve2-node"]
async def test_list_nodes_filters_by_label_case_insensitive(client: AsyncClient, headers: dict):
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
res = await client.get("/api/v1/nodes?label=PROXMOX", headers=headers)
assert res.status_code == 200
assert [n["label"] for n in res.json()] == ["Proxmox"]
async def test_list_nodes_filters_by_label_no_match(client: AsyncClient, headers: dict):
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
res = await client.get("/api/v1/nodes?label=nonexistent", headers=headers)
assert res.status_code == 200
assert res.json() == []
async def test_list_nodes_without_label_param_returns_all(client: AsyncClient, headers: dict):
"""Omitting label is fully backward compatible with the old unfiltered behavior."""
for label in ("A", "B", "C"):
await client.post("/api/v1/nodes", json={"type": "generic", "label": label, "status": "unknown"}, headers=headers)
res = await client.get("/api/v1/nodes", headers=headers)
assert res.status_code == 200
assert len(res.json()) == 3
+61
View File
@@ -167,3 +167,64 @@ def test_save_overrides_omits_mesh_credentials(tmp_path):
assert written["zigbee_sync_enabled"] is True
assert written["zigbee_sync_interval"] == 900
assert written["zwave_sync_interval"] == 1200
def _valid_oidc_settings(**overrides):
values = {
"secret_key": "test-secret-key-that-is-at-least-32-bytes",
"auth_mode": "oidc",
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
"oidc_client_id": "homelable",
"oidc_client_secret": "client-secret",
"oidc_redirect_uri": "https://homelable.example/api/v1/auth/oidc/callback",
"cors_origins": ["https://homelable.example"],
}
values.update(overrides)
return Settings(_env_file=None, **values)
def test_oidc_mode_requires_complete_client_configuration():
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL.*OIDC_CLIENT_ID.*OIDC_CLIENT_SECRET.*OIDC_REDIRECT_URI"):
Settings(secret_key="test-secret", auth_mode="oidc", _env_file=None)
def test_oidc_mode_requires_openid_scope():
with pytest.raises(ValueError, match="OIDC_SCOPES must contain 'openid'"):
_valid_oidc_settings(oidc_scopes="profile email")
def test_oidc_mode_requires_strong_session_secret():
with pytest.raises(ValueError, match="SECRET_KEY must be at least 32 bytes"):
_valid_oidc_settings(secret_key="too-short")
def test_oidc_secure_cookie_requires_https_callback():
with pytest.raises(ValueError, match="must use HTTPS"):
_valid_oidc_settings(oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback")
def test_oidc_secure_cookie_requires_https_discovery():
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL must use HTTPS"):
_valid_oidc_settings(
oidc_discovery_url="http://idp.local/application/o/homelable/.well-known/openid-configuration"
)
def test_oidc_insecure_dev_mode_allows_http_urls():
configured = _valid_oidc_settings(
oidc_cookie_secure=False,
oidc_discovery_url="http://idp.local/.well-known/openid-configuration",
oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback",
)
assert configured.oidc_cookie_secure is False
def test_oidc_mode_rejects_wildcard_cors():
with pytest.raises(ValueError, match="CORS_ORIGINS cannot contain"):
_valid_oidc_settings(cors_origins=["*"])
def test_valid_oidc_configuration_is_accepted():
configured = _valid_oidc_settings()
assert configured.auth_mode == "oidc"
assert configured.oidc_cookie_secure is True
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 82" width="64" height="82" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+192
View File
@@ -0,0 +1,192 @@
# OpenID Connect (OIDC) Authentication
Homelable can delegate login to your own identity provider (IdP) instead of the
built-in username/password. Sign-in uses the standard **Authorization Code flow
with PKCE**: the browser is redirected to your IdP, and on return Homelable
issues its own short-lived session cookie. Provider access/ID tokens are
exchanged server-side and **never** reach the browser.
Use it when you want single sign-on, per-user accounts managed at the IdP, MFA,
or central account revocation — Authentik, Keycloak, Authelia, Zitadel, Google,
Microsoft Entra ID, Okta, or any spec-compliant OpenID Connect provider.
> 🔒 **Server-dependent feature** — requires the Homelable backend. It is hidden
> in the no-backend standalone/demo build, which has no login at all.
---
## How it works
1. Homelable exposes its mode at `GET /api/v1/auth/config`. In OIDC mode the
login screen shows **Sign in with OpenID Connect**.
2. `GET /api/v1/auth/oidc/login` redirects to your IdP with `state`, `nonce`,
and a PKCE `code_challenge` (S256).
3. The IdP authenticates the user and redirects back to
`GET /api/v1/auth/oidc/callback`.
4. Homelable validates the response (signature via the IdP's JWKS, issuer,
audience, expiry, `nonce`, `state`, PKCE) and mints its **own** session token.
5. That token is stored in a `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax`
cookie. The browser never sees the provider tokens.
The display name shown in the app is the first present of
`preferred_username` → `name` → `email` → `sub`.
Local Bearer authentication and the MCP service key keep working unchanged, so
the API and the MCP server are unaffected.
---
## Prerequisites
1. A reachable **OpenID Connect** provider with a discovery document
(`.well-known/openid-configuration`).
2. Homelable served over **HTTPS** in production (required for the secure
session cookie).
3. A **confidential** OIDC client (client id **and** secret) registered at the
IdP, with Homelable's callback URL allow-listed as a redirect URI.
---
## Configuration
All settings live in `.env` (see `.env.example`). Switching to OIDC is
exclusive — it replaces local login. Existing installs stay on
`AUTH_MODE=local` until you change this.
```env
AUTH_MODE=oidc
# Pin CORS to the exact browser origin(s). Wildcard is rejected in OIDC mode.
CORS_ORIGINS=["https://homelable.example"]
# From your IdP:
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
OIDC_CLIENT_ID=homelable
OIDC_CLIENT_SECRET=replace-with-a-secret
# Must EXACTLY match a redirect URI registered at the IdP.
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
OIDC_SCOPES="openid profile email"
OIDC_COOKIE_SECURE=true
OIDC_SESSION_EXPIRE_MINUTES=480
OIDC_TRANSACTION_EXPIRE_SECONDS=600
```
Then restart the backend (`docker compose restart backend`).
### Reference
| Variable | Required | Default | Notes |
|---|---|---|---|
| `AUTH_MODE` | yes | `local` | Set to `oidc` to enable. |
| `OIDC_DISCOVERY_URL` | yes | — | The IdP's `.well-known/openid-configuration` URL. |
| `OIDC_CLIENT_ID` | yes | — | Confidential client id. |
| `OIDC_CLIENT_SECRET` | yes | — | Client secret. Keep it out of Git and Compose YAML. |
| `OIDC_REDIRECT_URI` | yes | — | Must equal the registered redirect **exactly**, path included. |
| `OIDC_SCOPES` | no | `openid profile email` | Must contain `openid`. |
| `OIDC_COOKIE_SECURE` | no | `true` | `true` requires an HTTPS redirect URI and enables the `__Host-` cookie prefix. Set `false` only for local HTTP testing. |
| `OIDC_SESSION_EXPIRE_MINUTES` | no | `480` | App session lifetime (5–1440). |
| `OIDC_TRANSACTION_EXPIRE_SECONDS` | no | `600` | Lifetime of the short login-flow cookie holding `state`/`nonce`/PKCE (60–3600). |
| `SECRET_KEY` | yes | — | Must be **≥ 32 bytes** in OIDC mode. Generate: `python3 -c "import secrets; print(secrets.token_hex(32))"`. |
| `CORS_ORIGINS` | yes | — | Exact browser origin(s). `*` is rejected in OIDC mode. |
### Fail-closed validation
The backend refuses to start in OIDC mode unless the configuration is complete
and safe. It errors if any of the following is true:
- any of `OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
`OIDC_REDIRECT_URI` is missing;
- `SECRET_KEY` is shorter than 32 bytes;
- `OIDC_SCOPES` does not contain `openid`;
- `OIDC_DISCOVERY_URL` or `OIDC_REDIRECT_URI` is not an HTTP(S) URL;
- `OIDC_COOKIE_SECURE=true` but `OIDC_REDIRECT_URI` is not HTTPS;
- `CORS_ORIGINS` contains `*`.
---
## Provider setup
The callback URL to register everywhere is:
```
https://<your-host>/api/v1/auth/oidc/callback
```
### Authentik
1. **Applications → Providers → Create → OAuth2/OpenID Provider.**
2. Client type **Confidential**; add the redirect URI above.
3. Note the client id/secret and the discovery URL:
`https://authentik.example/application/o/<app-slug>/.well-known/openid-configuration`.
4. Create an **Application** bound to that provider.
### Keycloak
1. In your realm: **Clients → Create client** (OpenID Connect), **Client
authentication ON** (confidential).
2. Set **Valid redirect URIs** to the callback URL.
3. Discovery URL:
`https://kc.example/realms/<realm>/.well-known/openid-configuration`.
4. Copy the secret from the **Credentials** tab.
### Authelia
1. Add Homelable to the `identity_providers.oidc.clients` list with a
`client_secret`, the redirect URI, and scopes `openid profile email`.
2. Discovery URL: `https://auth.example/.well-known/openid-configuration`.
### Google
1. **Google Cloud Console → APIs & Services → Credentials → OAuth client ID**
(type *Web application*).
2. Add the callback URL under **Authorized redirect URIs** (Google requires
HTTPS).
3. Discovery URL: `https://accounts.google.com/.well-known/openid-configuration`.
Any other spec-compliant provider works the same way — point
`OIDC_DISCOVERY_URL` at its discovery document.
---
## Behind a reverse proxy
Homelable derives the redirect target from `OIDC_REDIRECT_URI`, so set it to the
**public** HTTPS URL your users reach — not the internal container address. Make
sure the proxy:
- terminates TLS and forwards `/api/v1/auth/oidc/*` to the backend;
- passes the original `Host` and `X-Forwarded-Proto` headers;
- does not strip the `Cookie` / `Set-Cookie` headers on those routes.
---
## Logout & sessions
- **Logout** clears the Homelable session cookie (`POST /api/v1/auth/logout`).
- Sessions are stateless JWTs signed with `SECRET_KEY`; there is no server-side
revocation, so a session stays valid until `OIDC_SESSION_EXPIRE_MINUTES`
elapses. Keep the lifetime modest for sensitive deployments.
- Logout is local to Homelable; it does not sign the user out at the IdP.
---
## Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| IdP shows `redirect_uri_mismatch` | `OIDC_REDIRECT_URI` must equal the registered URI **exactly**, including scheme, host, port and the `/api/v1/auth/oidc/callback` path. |
| `OIDC authentication failed` (401) after IdP login | Wrong client secret, `openid` missing from scopes, or clock skew breaking `nonce`/`exp` — sync server time (NTP). |
| Logged out immediately / cookie not set | `OIDC_COOKIE_SECURE=true` while serving over HTTP. Serve HTTPS, or set `OIDC_COOKIE_SECURE=false` for local testing only. |
| `403 CSRF validation failed` on save/actions | The request `Origin` is not in `CORS_ORIGINS`, or a stale tab is missing the CSRF token — reload the page. |
| Backend won't start in OIDC mode | Re-read the error: a required `OIDC_*` value is missing, `SECRET_KEY` < 32 bytes, `CORS_ORIGINS` has `*`, or the redirect isn't HTTPS while the cookie is secure. |
| Login button doesn't appear | `AUTH_MODE` is still `local`, or the frontend can't reach `/api/v1/auth/config` — check the proxy and `CORS_ORIGINS`. |
---
## Reverting to local auth
Set `AUTH_MODE=local` (or remove it) and restart the backend. The
`AUTH_USERNAME` / `AUTH_PASSWORD_HASH` login is restored; the `OIDC_*` values are
ignored while in local mode.
+515 -121
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "3.1.0",
"version": "3.1.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -31,7 +31,6 @@
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -60,6 +59,7 @@
"globals": "^16.5.0",
"jsdom": "^28.1.0",
"lucide-react": "^1.7.0",
"shadcn": "^4.0.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
+174 -34
View File
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState } from 'react'
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout'
@@ -45,7 +45,13 @@ import { useThemeStore } from '@/stores/themeStore'
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { demoNodes, demoEdges } from '@/utils/demoData'
import { decideCanvasLoad, isNewUserCanvas } from '@/utils/canvasLoadDecision'
import { WalkthroughActionsProvider, type WalkthroughActionApi } from '@/walkthrough/actions'
import { WalkthroughInvite } from '@/walkthrough/WalkthroughInvite'
import { WalkthroughOverlay } from '@/walkthrough/WalkthroughOverlay'
import { DEMO_SCAN_RUNS, DEMO_PENDING_DEVICES } from '@/walkthrough/demoTourData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import { bootstrapAuth } from '@/auth/bootstrap'
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
@@ -57,12 +63,19 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export default function App() {
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore()
const { isAuthenticated, isInitialized } = useAuthStore()
const authBootstrapStarted = useRef(false)
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
useStatusPolling()
useEffect(() => {
if (STANDALONE || authBootstrapStarted.current) return
authBootstrapStarted.current = true
void bootstrapAuth()
}, [])
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
@@ -74,6 +87,18 @@ export default function App() {
const loadedDesignIdRef = useRef<string | null>(loadedDesignId)
useEffect(() => { loadedDesignIdRef.current = loadedDesignId }, [loadedDesignId])
// True while the last canvas load failed (backend down/error). Drives the error
// banner and keeps us from masking a real failure with the demo canvas.
const [loadError, setLoadError] = useState(false)
// True when the loaded canvas is the demo (a brand-new user). Reserved as the
// entry signal for the upcoming Getting Started walkthrough.
const [isNewUser, setIsNewUser] = useState(false)
// Getting Started tour: when true, the corresponding modal renders injected demo
// data instead of hitting the backend.
const [tourScanHistoryDemo, setTourScanHistoryDemo] = useState(false)
const [tourInventoryDemo, setTourInventoryDemo] = useState(false)
const [themeModalOpen, setThemeModalOpen] = useState(false)
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
@@ -151,55 +176,83 @@ export default function App() {
})
const loadCanvasFromApi = useCallback(async (designId?: string) => {
let res
try {
const res = await canvasApi.load(designId)
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) {
const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true])
)
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
// Clear when the target design has no floor plan, so it doesn't bleed
// across canvases when switching designs.
setFloorMap(savedFloorMap ?? null)
loadCanvas(rfNodes, rfEdges)
} else {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
res = await canvasApi.load(designId)
} catch {
// Backend down / errored. Surface it and STOP — never fall back to the demo
// canvas, which would hide the real error and look like a fresh account.
// Leave the on-screen canvas and provenance untouched so an autosave can't
// clobber real data with an empty canvas.
setLoadError(true)
toast.error('Could not load canvas — backend not responding')
return
}
const { nodes: apiNodes, edges: apiEdges } = res.data
const mode = decideCanvasLoad(apiNodes.length > 0, res.data.initialized === true)
if (mode === 'real') {
const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true])
)
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
// Clear when the target design has no floor plan, so it doesn't bleed
// across canvases when switching designs.
setFloorMap(savedFloorMap ?? null)
loadCanvas(rfNodes, rfEdges)
} else if (mode === 'empty') {
// Initialized but no nodes: the user cleared this canvas on purpose — respect
// it and keep it empty instead of re-seeding the demo.
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
setFloorMap(null)
loadCanvas([], [])
} else {
// Brand-new, never-saved canvas: seed the demo.
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
} finally {
// Record provenance so autosave writes back under the design just loaded.
setLoadedDesignId(designId ?? null)
}
setLoadError(false)
setIsNewUser(isNewUserCanvas(mode))
// Record provenance so autosave writes back under the design just loaded.
setLoadedDesignId(designId ?? null)
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
// localStorage, falling back to the demo canvas when it has never been saved.
const loadStandaloneCanvas = useCallback((designId: string) => {
const saved = standaloneStorage.loadCanvas(designId)
if (saved && saved.nodes.length > 0) {
// A stored entry (even with zero nodes) means the user has saved this canvas,
// so treat it as initialized and don't re-seed the demo on top of a canvas
// they deliberately cleared.
const mode = decideCanvasLoad((saved?.nodes.length ?? 0) > 0, saved !== null)
if (mode === 'real' && saved) {
if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style)
// Floor plans are backend-only; keep the store clear in standalone mode.
setFloorMap(null)
const migrated = migrateClusterHandles(saved.nodes, saved.edges)
loadCanvas(migrated.nodes, migrated.edges)
} else if (mode === 'empty' && saved) {
if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style)
setFloorMap(null)
loadCanvas([], [])
} else {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
setLoadError(false)
setIsNewUser(isNewUserCanvas(mode))
// Record provenance so autosave writes back under the design just loaded.
setLoadedDesignId(designId)
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
@@ -231,16 +284,73 @@ export default function App() {
await loadCanvasFromApi(targetId)
}
} catch {
// If API fails (e.g. fresh DB with no designs), fall back to demo data
loadCanvas(demoNodes, demoEdges)
// Backend unreachable/errored — surface it. Do NOT seed the demo: that would
// hide a real outage behind a fake "new account" canvas.
setLoadError(true)
toast.error('Could not reach backend — check the server and retry')
}
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas])
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId])
// Keep a ref so the auth effect can call the latest loader without listing it
// as a dependency (which would re-fire on every design switch).
const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas)
useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas])
// Retry a failed load from the error banner.
const handleRetryLoad = useCallback(() => {
setLoadError(false)
void loadDesignsAndCanvasRef.current()
}, [])
// Bridge the Getting Started tour steps to the App's modal controls. The overlay
// calls closeAll() before each step, then the step's action to open the target.
const walkthroughActions = useMemo<WalkthroughActionApi>(() => ({
closeAll: () => {
setScanConfigOpen(false)
setScanHistoryOpen(false)
setTourScanHistoryDemo(false)
setPendingModalOpen(false)
setTourInventoryDemo(false)
setEditNodeId(null)
setThemeModalOpen(false)
setZigbeeImportOpen(false)
// Clear any tour-driven multi-selection so the DetailPanel closes.
useCanvasStore.setState((s) => ({
nodes: s.nodes.map((n) => (n.selected ? { ...n, selected: false } : n)),
selectedNodeIds: [],
selectedNodeId: null,
}))
},
openScanConfig: () => setScanConfigOpen(true),
openScanHistoryDemo: () => {
setTourScanHistoryDemo(true)
setScanHistoryOpen(true)
},
openInventoryDemo: () => {
setTourInventoryDemo(true)
openPendingModal(undefined, 'pending')
},
editFirstNode: () => {
const first = useCanvasStore.getState().nodes.find((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
if (first) setEditNodeId(first.id)
},
selectTwoNodes: () => {
const ids = useCanvasStore.getState().nodes
.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
.slice(0, 2)
.map((n) => n.id)
if (ids.length < 2) return
const set = new Set(ids)
useCanvasStore.setState((s) => ({
nodes: s.nodes.map((n) => ({ ...n, selected: set.has(n.id) })),
selectedNodeIds: ids,
selectedNodeId: null,
}))
},
openStyle: () => setThemeModalOpen(true),
openZigbeeImport: () => setZigbeeImportOpen(true),
}), [openPendingModal])
// Load designs + canvas on auth (or immediately in standalone mode, which has
// no auth gate).
useEffect(() => {
@@ -820,12 +930,22 @@ export default function App() {
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
if (!STANDALONE && !isInitialized) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-sm text-muted-foreground">
Loading authentication…
</div>
)
}
if (!STANDALONE && !isAuthenticated) return <LoginPage />
return (
<WalkthroughActionsProvider value={walkthroughActions}>
<TooltipProvider>
<ReactFlowProvider>
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
{/* data-new-user marks a first-time (demo) canvas — the hook the upcoming
Getting Started walkthrough keys off. */}
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]" data-new-user={isNewUser}>
<Sidebar
onAddNode={() => setAddNodeOpen(true)}
onAddGroupRect={() => setAddGroupRectOpen(true)}
@@ -840,6 +960,21 @@ export default function App() {
onOpenPending={openPendingModal}
/>
<div className="flex flex-col flex-1 min-w-0">
{loadError && (
<div
role="alert"
className="flex items-center justify-between gap-3 bg-[#3d1418] border-b border-[#f85149] px-4 py-2 text-sm text-[#ffa198]"
>
<span>Backend not responding — the canvas could not be loaded.</span>
<button
type="button"
onClick={handleRetryLoad}
className="rounded border border-[#f85149] px-2 py-0.5 text-[#ffa198] hover:bg-[#f85149]/20"
>
Retry
</button>
</div>
)}
<Toolbar
onSave={handleSave}
onAutoLayout={handleAutoLayout}
@@ -975,6 +1110,7 @@ export default function App() {
<ScanHistoryModal
open={scanHistoryOpen}
onClose={() => setScanHistoryOpen(false)}
demoRuns={tourScanHistoryDemo ? DEMO_SCAN_RUNS : undefined}
/>
)}
@@ -1099,6 +1235,7 @@ export default function App() {
onClose={() => setPendingModalOpen(false)}
highlightId={pendingHighlightId}
initialStatus={pendingModalStatus}
demoDevices={tourInventoryDemo ? DEMO_PENDING_DEVICES : undefined}
/>
<ExportModal
@@ -1108,7 +1245,10 @@ export default function App() {
/>
<Toaster theme="dark" position="bottom-right" />
<WalkthroughInvite />
<WalkthroughOverlay />
</ReactFlowProvider>
</TooltipProvider>
</WalkthroughActionsProvider>
)
}
+26 -4
View File
@@ -6,7 +6,7 @@ type Interceptor<T> = {
}
interface MockInstance {
defaults: { baseURL?: string }
defaults: { baseURL?: string; withCredentials?: boolean }
interceptors: {
request: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
response: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
@@ -25,9 +25,9 @@ const instances = hoisted.instances as MockInstance[]
vi.mock('axios', () => {
return {
default: {
create: (cfg: { baseURL?: string }) => {
create: (cfg: { baseURL?: string; withCredentials?: boolean }) => {
const inst: MockInstance = {
defaults: { baseURL: cfg?.baseURL },
defaults: { baseURL: cfg?.baseURL, withCredentials: cfg?.withCredentials },
interceptors: {
request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
@@ -54,7 +54,7 @@ describe('api/client', () => {
const [api, publicApi] = instances
beforeEach(() => {
useAuthStore.setState({ token: null, isAuthenticated: false })
useAuthStore.setState({ token: null, csrfToken: null, isAuthenticated: false })
api.get.mockClear()
api.post.mockClear()
api.patch.mockClear()
@@ -66,6 +66,7 @@ describe('api/client', () => {
it('creates two axios instances with /api/v1 baseURL', () => {
expect(instances).toHaveLength(2)
expect(api.defaults.baseURL).toBe('/api/v1')
expect(api.defaults.withCredentials).toBe(true)
expect(publicApi.defaults.baseURL).toBe('/api/v1')
})
@@ -86,6 +87,18 @@ describe('api/client', () => {
expect((out as typeof cfg).headers.Authorization).toBeUndefined()
})
it('adds the in-memory OIDC CSRF token only to unsafe requests', () => {
useAuthStore.setState({ csrfToken: 'csrf-123' })
const post = { method: 'post', headers: {} as Record<string, string> }
const get = { method: 'get', headers: {} as Record<string, string> }
api.__req.fulfilled!(post)
api.__req.fulfilled!(get)
expect(post.headers['X-Homelable-CSRF']).toBe('csrf-123')
expect(get.headers['X-Homelable-CSRF']).toBeUndefined()
})
it('response interceptor passes through 2xx responses', () => {
const r = { status: 200, data: { ok: true } }
expect(api.__res.fulfilled!(r)).toBe(r)
@@ -125,6 +138,15 @@ describe('api/client', () => {
expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
})
it('authApi discovers auth publicly and manages the current session', () => {
mod.authApi.config()
expect(publicApi.get).toHaveBeenCalledWith('/auth/config')
mod.authApi.me()
expect(api.get).toHaveBeenCalledWith('/auth/me')
mod.authApi.logout()
expect(api.post).toHaveBeenCalledWith('/auth/logout')
})
it('canvasApi.load GETs /canvas', () => {
mod.canvasApi.load()
expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
+10 -1
View File
@@ -1,16 +1,22 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
import type { AuthMode, AuthUser } from '@/stores/authStore'
export const api = axios.create({
baseURL: '/api/v1',
withCredentials: true,
})
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
const publicApi = axios.create({ baseURL: '/api/v1' })
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
const { token, csrfToken } = useAuthStore.getState()
if (token) config.headers.Authorization = `Bearer ${token}`
const method = config.method?.toUpperCase() ?? 'GET'
if (csrfToken && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
config.headers['X-Homelable-CSRF'] = csrfToken
}
return config
})
@@ -23,8 +29,11 @@ api.interceptors.response.use(
)
export const authApi = {
config: () => publicApi.get<{ mode: AuthMode; oidc_login_url: string | null }>('/auth/config'),
login: (username: string, password: string) =>
api.post<{ access_token: string }>('/auth/login', { username, password }),
me: () => api.get<AuthUser>('/auth/me'),
logout: () => api.post('/auth/logout'),
}
export const canvasApi = {
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/stores/authStore'
vi.mock('@/api/client', () => ({
authApi: {
config: vi.fn(),
me: vi.fn(),
},
}))
import { authApi } from '@/api/client'
import { bootstrapAuth } from '../bootstrap'
describe('bootstrapAuth', () => {
beforeEach(() => {
sessionStorage.clear()
useAuthStore.setState({
token: null,
isAuthenticated: false,
isInitialized: false,
authMode: null,
authMethod: null,
oidcLoginUrl: null,
displayName: null,
csrfToken: null,
})
vi.mocked(authApi.config).mockReset()
vi.mocked(authApi.me).mockReset()
})
it('restores an OIDC cookie session without storing an IdP token', async () => {
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'oidc', oidc_login_url: '/api/v1/auth/oidc/login' },
} as never)
vi.mocked(authApi.me).mockResolvedValue({
data: {
subject: 'user-1',
display_name: 'Alice',
auth_method: 'oidc',
issuer: 'https://id.example/application/o/homelable/',
csrf_token: 'csrf-123',
},
} as never)
await bootstrapAuth()
const state = useAuthStore.getState()
expect(authApi.me).toHaveBeenCalledOnce()
expect(state.isAuthenticated).toBe(true)
expect(state.authMethod).toBe('oidc')
expect(state.token).toBeNull()
expect(state.csrfToken).toBe('csrf-123')
})
it('does not probe /auth/me for local mode without a persisted token', async () => {
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'local', oidc_login_url: null },
} as never)
await bootstrapAuth()
expect(authApi.me).not.toHaveBeenCalled()
expect(useAuthStore.getState()).toMatchObject({
authMode: 'local',
isAuthenticated: false,
isInitialized: true,
})
})
it('validates and retains a persisted local bearer token', async () => {
useAuthStore.setState({ token: 'local-token' })
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'local', oidc_login_url: null },
} as never)
vi.mocked(authApi.me).mockResolvedValue({
data: {
subject: 'admin',
display_name: 'admin',
auth_method: 'local',
issuer: null,
csrf_token: null,
},
} as never)
await bootstrapAuth()
expect(useAuthStore.getState()).toMatchObject({
token: 'local-token',
isAuthenticated: true,
authMethod: 'local',
})
})
it('fails closed when the backend rejects the session', async () => {
useAuthStore.setState({ token: 'expired-token', isAuthenticated: true })
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'local', oidc_login_url: null },
} as never)
vi.mocked(authApi.me).mockRejectedValue({ response: { status: 401 } })
await bootstrapAuth()
expect(useAuthStore.getState()).toMatchObject({
token: null,
isAuthenticated: false,
isInitialized: true,
})
})
it('leaves the auth mode unknown when discovery is unavailable', async () => {
vi.mocked(authApi.config).mockRejectedValue(new Error('network down'))
await bootstrapAuth()
expect(useAuthStore.getState()).toMatchObject({
authMode: null,
isAuthenticated: false,
isInitialized: true,
})
})
})
+33
View File
@@ -0,0 +1,33 @@
import { authApi } from '@/api/client'
import { useAuthStore } from '@/stores/authStore'
/**
* Discover the configured authentication mode and restore a valid session.
* OIDC sessions are represented only by the backend's HttpOnly cookie; local
* sessions retain their existing bearer token until /auth/me validates it.
*/
export async function bootstrapAuth(): Promise<void> {
const store = useAuthStore.getState()
try {
const { data: config } = await authApi.config()
store.setAuthConfig(config.mode, config.oidc_login_url)
const token = useAuthStore.getState().token
if (config.mode === 'oidc' || token) {
try {
const { data: user } = await authApi.me()
useAuthStore.getState().authenticate(user)
} catch {
// The response interceptor clears invalid credentials on 401. Also
// clear them for malformed/unreachable session checks, failing closed.
useAuthStore.getState().logout()
}
}
} catch {
// Keep authMode unknown when discovery is unreachable. The login screen
// reports that state without guessing which authentication mode is active.
} finally {
useAuthStore.getState().finishInitialization()
}
}
+83 -51
View File
@@ -1,17 +1,19 @@
import { useState } from 'react'
import { Network, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { Button, buttonVariants } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Logo } from '@/components/ui/Logo'
import { authApi } from '@/api/client'
import { useAuthStore } from '@/stores/authStore'
import { cn } from '@/lib/utils'
export function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const login = useAuthStore((s) => s.login)
const { authMode, oidcLoginUrl, login } = useAuthStore()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -42,62 +44,92 @@ export function LoginPage() {
<div className="relative w-full max-w-sm px-4">
{/* Logo */}
<div className="flex flex-col items-center gap-3 mb-8">
<div className="flex items-center justify-center w-14 h-14 rounded-xl bg-[#00d4ff]/10 border border-[#00d4ff]/20">
<Network size={28} className="text-[#00d4ff]" />
</div>
<Logo size={64} showText={false} />
<div className="text-center">
<h1 className="text-xl font-semibold text-foreground tracking-wide">Homelable</h1>
<h1 className="text-xl font-semibold tracking-tight">
<span className="text-[#e6edf3]">Home</span>
<span className="text-[#00d4ff]">lable</span>
</h1>
<p className="text-xs text-muted-foreground mt-0.5">HomeLab Visualizer</p>
</div>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
placeholder="admin"
required
/>
{authMode === 'local' && (
<>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
placeholder="admin"
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
required
/>
</div>
{error && (
<p className="text-xs text-[#f85149] text-center">{error}</p>
)}
<Button
type="submit"
disabled={loading}
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
>
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
</Button>
</form>
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
Credentials configured in <span className="font-mono">.env</span>
</p>
</>
)}
{authMode === 'oidc' && (
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
<p className="text-sm text-muted-foreground">
Continue through the configured OpenID Connect provider.
</p>
<a
href={oidcLoginUrl ?? '/api/v1/auth/oidc/login'}
className={cn(
buttonVariants(),
'bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium',
)}
>
Sign in with OpenID Connect
</a>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
required
/>
{authMode === null && (
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
<p className="text-sm text-[#f85149]">Could not load authentication configuration.</p>
<Button type="button" variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
{error && (
<p className="text-xs text-[#f85149] text-center">{error}</p>
)}
<Button
type="submit"
disabled={loading}
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
>
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
</Button>
</form>
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
Credentials configured in <span className="font-mono">.env</span>
</p>
)}
</div>
</div>
)
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
import { LoginPage } from '../LoginPage'
import { useAuthStore } from '@/stores/authStore'
@@ -13,7 +13,12 @@ import { authApi } from '@/api/client'
describe('LoginPage', () => {
beforeEach(() => {
useAuthStore.setState({ token: null, isAuthenticated: false })
useAuthStore.setState({
token: null,
isAuthenticated: false,
authMode: 'local',
oidcLoginUrl: null,
})
vi.mocked(authApi.login).mockReset()
})
@@ -30,6 +35,41 @@ describe('LoginPage', () => {
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined()
})
it('renders the official Homelable logo mark — not a generic lucide icon', () => {
render(<LoginPage />)
const svg = screen.getByTestId('logo-mark')
// The official mark from docs/logo/icon.svg, not lucide's Network glyph
expect(svg.querySelector('path')?.getAttribute('d'))
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
expect(svg.classList.contains('lucide')).toBe(false)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Homelable')
expect(screen.getByText('HomeLab Visualizer')).toBeDefined()
})
it('renders only the provider redirect in OIDC mode', () => {
useAuthStore.setState({
authMode: 'oidc',
oidcLoginUrl: '/api/v1/auth/oidc/login',
})
render(<LoginPage />)
expect(screen.queryByLabelText('Username')).toBeNull()
expect(screen.queryByLabelText('Password')).toBeNull()
expect(screen.getByRole('link', { name: 'Sign in with OpenID Connect' }))
.toHaveAttribute('href', '/api/v1/auth/oidc/login')
})
it('does not guess an auth mode when discovery failed', () => {
useAuthStore.setState({ authMode: null })
render(<LoginPage />)
expect(screen.getByText('Could not load authentication configuration.')).toBeDefined()
expect(screen.queryByLabelText('Username')).toBeNull()
expect(screen.getByRole('button', { name: 'Retry' })).toBeDefined()
})
// ── Security checks ──────────────────────────────────────────────────────
it('password field type is "password" — not rendered as plain text', () => {
@@ -102,7 +142,9 @@ describe('LoginPage', () => {
await waitFor(() => {
expect((screen.getByRole('button', { name: '' }) as HTMLButtonElement).disabled).toBe(true)
})
resolve({ data: { access_token: 'tok' } })
await act(async () => {
resolve({ data: { access_token: 'tok' } })
})
})
it('calls authApi.login with credentials via POST body (not URL params)', async () => {
@@ -44,9 +44,11 @@ vi.mock('@/utils/nodeColors', () => ({
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
}))
vi.mock('@/utils/nodeIcons', () => ({
// Partial mock: node-type icon resolution is stubbed, but the brand/lucide
// resolver stays real so service icons render as they do in the app.
vi.mock('@/utils/nodeIcons', async (importActual) => ({
...(await importActual<typeof import('@/utils/nodeIcons')>()),
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
}))
vi.mock('@/utils/maskIp', () => ({
@@ -252,6 +254,27 @@ describe('BaseNode — services visibility toggle', () => {
expect(screen.queryByRole('link', { name: /ssh/i })).toBeNull()
})
it('renders the brand icon of a service that has one', () => {
renderBaseNode({
ip: '192.168.1.10',
custom_colors: { show_services: true },
services: [{ service_name: 'plex', port: 32400, protocol: 'tcp', icon: 'brand:plex' }],
})
const img = screen.getByAltText('plex') as HTMLImageElement
expect(img.src).toContain('dashboard-icons/svg/plex.svg')
})
it('renders no service icon when the service has none', () => {
renderBaseNode({
ip: '192.168.1.10',
custom_colors: { show_services: true },
services: [{ service_name: 'ssh', port: 22, protocol: 'tcp' }],
})
expect(screen.queryByRole('img')).toBeNull()
})
})
describe('BaseNode — legacy hardware fallback', () => {
@@ -5,6 +5,7 @@ import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
import { NodeIcon } from '@/components/ui/NodeIcon'
import { ServiceIcon } from '@/components/ui/ServiceIcon'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
@@ -172,13 +173,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
{/* LEFT: service name */}
<span
className="font-medium truncate"
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
title={svc.service_name}
>
{svc.service_name}
{/* LEFT: icon + service name */}
<span className="flex items-center gap-1 min-w-0">
<ServiceIcon iconKey={svc.icon} size={11} />
<span
className="font-medium truncate"
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
title={svc.service_name}
>
{svc.service_name}
</span>
</span>
{/* RIGHT: path + port */}
@@ -0,0 +1,93 @@
import { createElement, useState } from 'react'
import modalStyles from './modal-interactive.module.css'
import { Input } from '@/components/ui/input'
import { ICON_REGISTRY, ICON_CATEGORIES, isBrandIconKey } from '@/utils/nodeIcons'
import { BrandIconPicker } from './BrandIconPicker'
interface IconPickerPanelProps {
/** Current icon key — a lucide registry key or `brand:<slug>`. */
value?: string
/** Called with the new key, or `undefined` when the current generic icon is toggled off. */
onSelect: (key: string | undefined) => void
/** Focus the generic-icon search field on mount. */
autoFocus?: boolean
}
/**
* Shared Generic/Brand icon picker body. Used by NodeModal and ServiceModal so
* both surfaces offer the same lucide registry + dashboard-icons brand catalog.
*/
export function IconPickerPanel({ value, onSelect, autoFocus }: IconPickerPanelProps) {
const [search, setSearch] = useState('')
const [tab, setTab] = useState<'generic' | 'brand'>(isBrandIconKey(value) ? 'brand' : 'generic')
const tabClass = (active: boolean) =>
`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
active ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`
return (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
<button type="button" role="tab" aria-selected={tab === 'generic'} onClick={() => setTab('generic')} className={tabClass(tab === 'generic')}>
Generic
</button>
<button type="button" role="tab" aria-selected={tab === 'brand'} onClick={() => setTab('brand')} className={tabClass(tab === 'brand')}>
Brand
</button>
</div>
{tab === 'brand' ? (
<BrandIconPicker value={value} onSelect={onSelect} />
) : (
<>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search icons…"
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
autoFocus={autoFocus}
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const q = search.toLowerCase()
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat && (search === '' || e.label.toLowerCase().includes(q) || e.key.includes(q))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = value === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => onSelect(isSelected ? undefined : entry.key)}
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
aria-label={`Select icon ${entry.label}`}
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</>
)}
</div>
)
}
+6 -81
View File
@@ -10,8 +10,8 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSepa
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod, type NodeTypeStyle } from '@/types'
import { useThemeStore } from '@/stores/themeStore'
import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { BrandIconPicker } from './BrandIconPicker'
import { ICON_REGISTRY, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { IconPickerPanel } from './IconPickerPanel'
import { MAX_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
@@ -142,9 +142,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const merged = { ...DEFAULT_DATA, ...initial }
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
const [form, setForm] = useState<Partial<NodeData>>(merged)
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
const [labelError, setLabelError] = useState(false)
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
const showServicesEnabled = form.custom_colors?.show_services === true
@@ -298,85 +296,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{/* Inline icon picker - full width, shown below the type+icon row */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
<button
type="button"
role="tab"
aria-selected={iconTab === 'generic'}
onClick={() => setIconTab('generic')}
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`}
>
Generic
</button>
<button
type="button"
role="tab"
aria-selected={iconTab === 'brand'}
onClick={() => setIconTab('brand')}
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`}
>
Brand
</button>
</div>
{iconTab === 'brand' ? (
<BrandIconPicker
value={form.custom_icon}
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
/>
) : (
<>
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…"
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
<div className="col-span-2">
<IconPickerPanel
value={form.custom_icon}
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
autoFocus
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat &&
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = form.custom_icon === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
aria-label={`Select icon ${entry.label}`}
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</>
)}
</div>
)}
@@ -25,6 +25,8 @@ interface PendingDevicesModalProps {
onClose: () => void
highlightId?: string
initialStatus?: 'pending' | 'hidden'
/** Getting Started tour: render these canned devices and skip the backend fetch. */
demoDevices?: PendingDevice[]
}
const PORT_COLORS: Record<number, string> = {
@@ -113,7 +115,7 @@ function injectAutoEdges(edges: AutoEdge[] | undefined) {
}))
}
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices }: PendingDevicesModalProps) {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null)
@@ -136,6 +138,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
const load = useCallback(async () => {
// Tour/demo mode: show injected devices, never touch the backend.
if (demoDevices) { setDevices(statusFilter === 'pending' ? demoDevices : []); return }
setLoading(true)
try {
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
@@ -145,7 +149,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
} finally {
setLoading(false)
}
}, [statusFilter])
}, [statusFilter, demoDevices])
useEffect(() => { if (open) load() }, [open, load])
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
@@ -20,6 +20,8 @@ export interface ScanRun {
interface ScanHistoryModalProps {
open: boolean
onClose: () => void
/** Getting Started tour: render these canned runs and skip the backend fetch. */
demoRuns?: ScanRun[]
}
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
@@ -85,7 +87,7 @@ function runDuration(r: ScanRun, now: number): string {
return formatDuration(end - start)
}
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
export function ScanHistoryModal({ open, onClose, demoRuns }: ScanHistoryModalProps) {
const [runs, setRuns] = useState<ScanRun[]>([])
const [loading, setLoading] = useState(false)
const [stopping, setStopping] = useState<string | null>(null)
@@ -95,6 +97,8 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
const prevRunsRef = useRef<ScanRun[]>([])
const load = useCallback(async () => {
// Tour/demo mode: show injected runs, never touch the backend.
if (demoRuns) { setRuns(demoRuns); prevRunsRef.current = demoRuns; return }
setLoading(true)
try {
const res = await scanApi.runs()
@@ -127,7 +131,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
} finally {
setLoading(false)
}
}, [])
}, [demoRuns])
// Load when opened; reset prior-state tracker so we don't replay old transitions
useEffect(() => {
@@ -155,6 +159,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
}, [open, runs])
const handleStop = async (runId: string) => {
if (demoRuns) return // tour mode — no backend
setStopping(runId)
try {
await scanApi.stop(runId)
@@ -0,0 +1,191 @@
import { createElement, useState } from 'react'
import { ChevronDown, RotateCcw, Circle } from 'lucide-react'
import modalStyles from './modal-interactive.module.css'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ICON_REGISTRY, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { IconPickerPanel } from './IconPickerPanel'
import { EMPTY_SERVICE_FORM, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
interface ServiceModalProps {
open: boolean
onClose: () => void
onSubmit: (data: ServiceSubmitData) => void
initial?: ServiceFormData
title?: string
confirmLabel?: string
}
export function ServiceModal({ open, onClose, onSubmit, initial, title = 'Add Service', confirmLabel = 'Add' }: ServiceModalProps) {
const [form, setForm] = useState<ServiceFormData>(initial ?? EMPTY_SERVICE_FORM)
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [nameError, setNameError] = useState(false)
const set = <K extends keyof ServiceFormData>(key: K, value: ServiceFormData[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const setPort = (value: string) => set('port', value.replace(/\D/g, '').slice(0, 5))
const clampPort = () => {
if (!form.port) return
const parsed = Number.parseInt(form.port, 10)
set('port', Number.isFinite(parsed) ? String(Math.max(1, Math.min(65535, parsed))) : '')
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const name = form.service_name.trim()
if (!name) {
setNameError(true)
return
}
const trimmedPort = form.port.trim()
const port = trimmedPort === '' ? undefined : Number.parseInt(trimmedPort, 10)
if (port != null && (Number.isNaN(port) || port < 1 || port > 65535)) return
const path = form.path.trim()
onSubmit({
service_name: name,
protocol: form.protocol,
port,
path: path || undefined,
icon: form.icon,
})
onClose()
}
const iconPreview = () => {
if (isBrandIconKey(form.icon)) {
const slug = brandIconSlug(form.icon!)
return (
<>
<img src={brandIconUrl(slug)} alt={slug} width={13} height={13} className="shrink-0" style={{ width: 13, height: 13, objectFit: 'contain' }} />
<span className="text-foreground truncate">{slug}</span>
</>
)
}
const entry = ICON_REGISTRY.find((e) => e.key === form.icon)
if (entry) {
return (
<>
{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}
<span className="text-foreground truncate">{entry.label}</span>
</>
)
}
// A key we can't resolve (e.g. a legacy scanner signature name) is still
// shown verbatim so saving doesn't look like it silently dropped it.
return (
<>
{createElement(Circle, { size: 13, className: 'text-muted-foreground shrink-0' })}
<span className="text-muted-foreground truncate">{form.icon ?? 'None'}</span>
</>
)
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
{/* Name */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Name *</Label>
<Input
value={form.service_name}
onChange={(e) => { set('service_name', e.target.value); if (nameError) setNameError(false) }}
placeholder="Service name"
autoFocus
className={`bg-[#21262d] text-sm h-8 ${nameError ? 'border-[#f85149]' : 'border-[#30363d]'}`}
/>
{nameError && <span className="text-[10px] text-[#f85149]">Name is required</span>}
</div>
{/* Port + protocol */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Port</Label>
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.port}
onChange={(e) => setPort(e.target.value)}
onBlur={clampPort}
placeholder="Port"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Protocol</Label>
<select
value={form.protocol}
aria-label="Protocol"
onChange={(e) => set('protocol', e.target.value as 'tcp' | 'udp')}
className={`bg-[#21262d] border border-[#30363d] text-sm h-8 px-2 text-foreground ${modalStyles['modal-radius']}`}
>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
</div>
{/* Path */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Path</Label>
<Input
value={form.path}
onChange={(e) => set('path', e.target.value)}
placeholder="Path (/admin)"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* Icon */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Icon</Label>
{form.icon && (
<button
type="button"
onClick={() => { set('icon', undefined); setIconPickerOpen(false) }}
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<RotateCcw size={10} /> Reset
</button>
)}
</div>
<button
type="button"
onClick={() => setIconPickerOpen((o) => !o)}
className={`flex items-center justify-between gap-2 h-8 px-3 bg-[#21262d] border border-[#30363d] text-sm transition-colors w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}
aria-label="Icon picker trigger"
>
<span className="flex items-center gap-2 min-w-0">{iconPreview()}</span>
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
</button>
{iconPickerOpen && (
<IconPickerPanel
value={form.icon}
onSelect={(key) => { set('icon', key); setIconPickerOpen(false) }}
/>
)}
</div>
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
Cancel
</Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
{confirmLabel}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -11,6 +11,7 @@ import {
type ZwaveConfigData,
} from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { useWalkthroughStore } from '@/stores/walkthroughStore'
import { toast } from 'sonner'
import {
type AlignmentSettings,
@@ -401,6 +402,18 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
Saves silently after this many seconds with no changes. Manual Ctrl+S still works.
</p>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-foreground">Getting started tour</span>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => { useWalkthroughStore.getState().start(); onClose() }}
>
Restart walkthrough
</Button>
</div>
</div>
</div>
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { IconPickerPanel } from '../IconPickerPanel'
describe('IconPickerPanel', () => {
it('opens on the Generic tab by default', () => {
render(<IconPickerPanel onSelect={vi.fn()} />)
expect(screen.getByRole('tab', { name: 'Generic' }).getAttribute('aria-selected')).toBe('true')
})
it('opens on the Brand tab when the current value is a brand key', () => {
render(<IconPickerPanel value="brand:plex" onSelect={vi.fn()} />)
expect(screen.getByRole('tab', { name: 'Brand' }).getAttribute('aria-selected')).toBe('true')
})
it('emits the lucide key when a generic icon is picked', () => {
const onSelect = vi.fn()
render(<IconPickerPanel onSelect={onSelect} />)
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
expect(onSelect).toHaveBeenCalledWith('database')
})
it('emits undefined when the already-selected generic icon is clicked again', () => {
const onSelect = vi.fn()
render(<IconPickerPanel value="database" onSelect={onSelect} />)
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
expect(onSelect).toHaveBeenCalledWith(undefined)
})
it('filters the generic grid by search text', () => {
render(<IconPickerPanel onSelect={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText('Search icons…'), { target: { value: 'grafana' } })
expect(screen.getByLabelText('Select icon Grafana / Kibana')).toBeDefined()
expect(screen.queryByLabelText('Select icon Database (SQL/NoSQL)')).toBeNull()
})
it('emits a prefixed key when a brand icon is picked', () => {
const onSelect = vi.fn()
render(<IconPickerPanel onSelect={onSelect} />)
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
expect(onSelect).toHaveBeenCalledWith('brand:plex')
})
it('keeps the generic search box out of the Brand tab', () => {
render(<IconPickerPanel onSelect={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
})
})
@@ -0,0 +1,182 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ServiceModal } from '../ServiceModal'
import { serviceToForm } from '@/utils/serviceForm'
function setup(props: Partial<React.ComponentProps<typeof ServiceModal>> = {}) {
const onSubmit = vi.fn()
const onClose = vi.fn()
render(<ServiceModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
return { onSubmit, onClose }
}
const submit = (label = 'Add') =>
fireEvent.click(screen.getByRole('button', { name: label }))
describe('ServiceModal', () => {
describe('form submission', () => {
it('submits name, port, protocol and path', () => {
const { onSubmit, onClose } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
fireEvent.change(screen.getByLabelText('Protocol'), { target: { value: 'udp' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
submit()
expect(onSubmit).toHaveBeenCalledWith({
service_name: 'nginx',
protocol: 'udp',
port: 80,
path: '/admin',
icon: undefined,
})
expect(onClose).toHaveBeenCalledOnce()
})
it('submits without a port', () => {
const { onSubmit } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } })
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ service_name: 'health', port: undefined, path: undefined }))
})
it('trims whitespace off the name and path', () => {
const { onSubmit } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: ' nginx ' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: ' /admin ' } })
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ service_name: 'nginx', path: '/admin' }))
})
it('blocks submission and flags the field when the name is blank', () => {
const { onSubmit, onClose } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: ' ' } })
submit()
expect(onSubmit).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByText('Name is required')).toBeDefined()
})
it('clears the name error once the user types', () => {
setup()
submit()
expect(screen.getByText('Name is required')).toBeDefined()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'n' } })
expect(screen.queryByText('Name is required')).toBeNull()
})
it('strips non-digits from the port field', () => {
setup()
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '8a0b80' } })
expect((screen.getByPlaceholderText('Port') as HTMLInputElement).value).toBe('8080')
})
it('clamps an out-of-range port on blur', () => {
setup()
const port = screen.getByPlaceholderText('Port')
fireEvent.change(port, { target: { value: '99999' } })
fireEvent.blur(port)
expect((port as HTMLInputElement).value).toBe('65535')
})
it('calls onClose without submitting on Cancel', () => {
const { onSubmit, onClose } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onSubmit).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalledOnce()
})
})
describe('edit mode', () => {
const initial = serviceToForm({ port: 80, protocol: 'tcp', service_name: 'nginx', path: '/admin', icon: 'brand:nginx' })
it('prefills the form from an existing service', () => {
setup({ initial, title: 'Edit Service', confirmLabel: 'Save' })
expect((screen.getByPlaceholderText('Service name') as HTMLInputElement).value).toBe('nginx')
expect((screen.getByPlaceholderText('Port') as HTMLInputElement).value).toBe('80')
expect((screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement).value).toBe('/admin')
expect(screen.getByText('Edit Service')).toBeDefined()
})
it('keeps the existing icon when nothing else changes', () => {
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
submit('Save')
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'brand:nginx' }))
})
it('clears a port that the user emptied', () => {
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '' } })
submit('Save')
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ port: undefined }))
})
it('clears a path that the user emptied', () => {
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '' } })
submit('Save')
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ path: undefined }))
})
})
describe('icon picker', () => {
it('shows "None" until an icon is picked', () => {
setup()
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('None')
})
it('shows an unresolvable legacy key verbatim rather than "None"', () => {
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'grafana', icon: 'bar-chart-2' }) })
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('bar-chart-2')
})
it('keeps an unresolvable legacy key on save instead of dropping it', () => {
const { onSubmit } = setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'grafana', icon: 'bar-chart-2' }), confirmLabel: 'Save' })
submit('Save')
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'bar-chart-2' }))
})
it('selects a generic lucide icon', () => {
const { onSubmit } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'db' } })
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'database' }))
})
it('selects a brand icon from the Brand tab', () => {
const { onSubmit } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'plex' } })
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'brand:plex' }))
})
it('opens on the Brand tab when the service already carries a brand icon', () => {
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
expect(screen.getByRole('tab', { name: 'Brand' }).getAttribute('aria-selected')).toBe('true')
})
it('previews the selected brand icon on the trigger', () => {
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('plex')
})
it('resets the icon back to none', () => {
const { onSubmit } = setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
fireEvent.click(screen.getByRole('button', { name: /Reset/ }))
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: undefined }))
})
it('hides the Reset button when no icon is set', () => {
setup()
expect(screen.queryByRole('button', { name: /Reset/ })).toBeNull()
})
})
})
@@ -131,6 +131,9 @@ describe('SettingsModal', () => {
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never)
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByDisplayValue('60')
// Wait for the async proxmox getConfig to hydrate the toggle before saving —
// otherwise Save can fire with the default sync_enabled=false under CI load.
await waitFor(() => expect(screen.getByLabelText('Toggle Proxmox auto-sync')).toBeChecked())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(proxmoxApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 3600 })
@@ -174,6 +177,9 @@ describe('SettingsModal', () => {
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByDisplayValue('60')
await screen.findByText('Zigbee auto-sync')
// Wait for the async zigbee getConfig to hydrate the toggle before saving —
// otherwise Save can fire with the default sync_enabled=false under CI load.
await waitFor(() => expect(screen.getByLabelText('Toggle Zigbee auto-sync')).toBeChecked())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(zigbeeApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 1800 })
+109 -107
View File
@@ -9,14 +9,17 @@ import { getServiceUrl } from '@/utils/serviceUrl'
import { splitIps } from '@/utils/maskIp'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import { formatTimestamp } from '@/utils/timeFormat'
import { ServiceModal } from '@/components/modals/ServiceModal'
import { serviceToForm, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
import { ServiceIcon } from '@/components/ui/ServiceIcon'
import type { Node } from '@xyflow/react'
interface DetailPanelProps {
onEdit: (id: string) => void
}
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
/** Open service editor: `index === null` means "add a new service". */
type SvcModalState = { nodeId: string; index: number | null; form?: ServiceFormData }
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
@@ -25,10 +28,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
const [addingForNode, setAddingForNode] = useState<string | null>(null)
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
const [svcModal, setSvcModal] = useState<SvcModalState | null>(null)
const [groupName, setGroupName] = useState('')
const [creatingGroup, setCreatingGroup] = useState(false)
@@ -39,6 +39,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
const [dragPropIndex, setDragPropIndex] = useState<number | null>(null)
const [dragOverPropIndex, setDragOverPropIndex] = useState<number | null>(null)
const [dragSvcIndex, setDragSvcIndex] = useState<number | null>(null)
const [dragOverSvcIndex, setDragOverSvcIndex] = useState<number | null>(null)
// Multi-select panel
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
@@ -87,8 +89,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
}
// Normal single-node panel
const addingService = addingForNode === node.id
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
const openSvcModal = svcModal?.nodeId === node.id ? svcModal : null
const { data } = node
const services = data.services ?? []
const statusColor = STATUS_COLORS[data.status]
@@ -102,60 +103,55 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
}
}
const handleAddService = () => {
const trimmedPort = newSvc.port.trim()
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!newSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
const handleSubmitService = (data: ServiceSubmitData) => {
if (!openSvcModal) return
snapshotHistory()
const path = newSvc.path.trim()
const svc: ServiceInfo = {
...(port != null ? { port } : {}),
protocol: newSvc.protocol,
service_name: newSvc.service_name.trim(),
...(path ? { path } : {}),
if (openSvcModal.index === null) {
const svc: ServiceInfo = {
...(data.port != null ? { port: data.port } : {}),
protocol: data.protocol,
service_name: data.service_name,
...(data.path ? { path: data.path } : {}),
...(data.icon ? { icon: data.icon } : {}),
}
updateNode(node.id, { services: [...services, svc] })
return
}
updateNode(node.id, { services: [...services, svc] })
setNewSvc(EMPTY_FORM)
setAddingForNode(null)
const updated = services.map((svc, i) =>
i === openSvcModal.index
? {
...svc,
protocol: data.protocol,
service_name: data.service_name,
port: data.port,
path: data.path,
icon: data.icon,
}
: svc
)
updateNode(node.id, { services: updated })
}
const handleRemoveService = (index: number) => {
snapshotHistory()
const updated = services.filter((_, i) => i !== index)
updateNode(node.id, { services: updated })
if (editingIndex === index) setEditingFor(null)
if (openSvcModal?.index === index) setSvcModal(null)
}
const handleStartEdit = (index: number) => {
const svc = services[index]
if (!svc) return
setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' })
setEditingFor({ nodeId: node.id, index })
setAddingForNode(null)
setSvcModal({ nodeId: node.id, index, form: serviceToForm(svc) })
}
const handleSaveEdit = () => {
if (editingIndex === null) return
const trimmedPort = editSvc.port.trim()
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!editSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
const handleReorderService = (from: number, to: number) => {
if (from === to || from < 0 || to < 0 || from >= services.length || to >= services.length) return
snapshotHistory()
const path = editSvc.path.trim()
const updated = services.map((svc, i) =>
i === editingIndex
? {
...svc,
protocol: editSvc.protocol,
service_name: editSvc.service_name.trim(),
...(port != null ? { port } : { port: undefined }),
...(path ? { path } : { path: undefined }),
}
: svc
)
updateNode(node.id, { services: updated })
setEditingFor(null)
const reordered = [...services]
const [moved] = reordered.splice(from, 1)
reordered.splice(to, 0, moved)
updateNode(node.id, { services: reordered })
}
// --- Property handlers ---
@@ -340,25 +336,50 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
<button onClick={() => setSvcModal({ nodeId: node.id, index: null })} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
<Plus size={10} /> Add
</button>
</div>
{addingService && <ServiceForm form={newSvc} onChange={setNewSvc} onConfirm={handleAddService} onCancel={() => setAddingForNode(null)} confirmLabel="Add" autoFocus />}
{services.length > 0 && (
<div className="flex flex-col gap-1.5">
{services.map((svc, i) =>
editingIndex === i ? (
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
) : (
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
)
)}
{services.map((svc, i) => (
<ServiceBadge
key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`}
svc={svc}
host={host}
status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]}
draggable={services.length > 1}
isDragging={dragSvcIndex === i}
isDragOver={dragOverSvcIndex === i && dragSvcIndex !== i}
onDragStart={() => setDragSvcIndex(i)}
onDragEnter={() => { if (dragSvcIndex !== null) setDragOverSvcIndex(i) }}
onDragEnd={() => { setDragSvcIndex(null); setDragOverSvcIndex(null) }}
onDrop={() => {
if (dragSvcIndex !== null) handleReorderService(dragSvcIndex, i)
setDragSvcIndex(null)
setDragOverSvcIndex(null)
}}
onEdit={() => handleStartEdit(i)}
onRemove={() => handleRemoveService(i)}
/>
))}
</div>
)}
{services.length === 0 && !addingService && <p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>}
{services.length === 0 && <p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>}
</div>
{openSvcModal && (
<ServiceModal
key={`svc-${openSvcModal.index ?? 'new'}`}
open
onClose={() => setSvcModal(null)}
onSubmit={handleSubmitService}
initial={openSvcModal.form}
title={openSvcModal.index === null ? 'Add Service' : 'Edit Service'}
confirmLabel={openSvcModal.index === null ? 'Add' : 'Save'}
/>
)}
{data.notes && (
<div className="px-4 py-3 border-t border-border">
<div className="text-xs text-muted-foreground mb-1">Notes</div>
@@ -516,6 +537,7 @@ function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGro
</>
) : (
<Button
data-tour="create-group"
size="sm"
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
variant="ghost"
@@ -667,55 +689,6 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
)
}
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
onConfirm: () => void
onCancel: () => void
confirmLabel: string
autoFocus?: boolean
}) {
const setPort = (value: string) => {
const digitsOnly = value.replace(/\D/g, '').slice(0, 5)
onChange({ ...form, port: digitsOnly })
}
const clampPort = (value: string) => {
if (!value) return ''
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) return ''
return String(Math.max(1, Math.min(65535, parsed)))
}
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<div className="flex gap-1.5">
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.port}
onChange={(e) => setPort(e.target.value)}
onBlur={() => onChange({ ...form, port: clampPort(form.port) })}
placeholder="Port"
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
<Input value={form.path} onChange={(e) => onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<div className="flex gap-1.5">
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
</div>
</div>
)
}
// --- Property components ---
function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
@@ -852,7 +825,20 @@ const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
}
function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onEdit, onRemove }: {
svc: ServiceInfo
host?: string
status?: ServiceStatus
draggable: boolean
isDragging: boolean
isDragOver: boolean
onDragStart: () => void
onDragEnter: () => void
onDragEnd: () => void
onDrop: () => void
onEdit: () => void
onRemove: () => void
}) {
const url = getServiceUrl(svc, host)
// Manually-added services carry no category, so they fell back to grey even
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
@@ -863,11 +849,27 @@ function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInf
return (
<div
draggable={draggable}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragEnd={onDragEnd}
onDrop={(e) => { e.preventDefault(); onDrop() }}
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors min-w-0"
style={{ background: '#21262d', borderColor: '#30363d' }}
style={{
background: '#21262d',
borderColor: isDragOver ? '#00d4ff' : '#30363d',
opacity: isDragging ? 0.4 : 1,
}}
>
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{draggable && (
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
{createElement(GripVertical, { size: 11 })}
</span>
)}
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<ServiceIcon iconKey={svc.icon} size={12} className="shrink-0" color={color} />
{url ? (
<a
+22 -10
View File
@@ -5,7 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore'
import { designsApi, mediaApi } from '@/api/client'
import { authApi, designsApi, mediaApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
@@ -47,6 +47,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
const { nodes, hasUnsavedChanges, floorMap, setFloorMap } = useCanvasStore()
const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce)
const handleLogout = useCallback(async () => {
try {
await authApi.logout()
logout()
} catch {
toast.error('Could not end the current session')
}
}, [logout])
const openDesignModal = useCallback((m: { mode: 'create' | 'edit'; design?: Design }) => {
setOpenSeq((s) => s + 1)
setDesignModal(m)
@@ -268,13 +277,13 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{/* Actions */}
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} />}
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} dataTour="add-node" />
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} dataTour="add-zone" />
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} dataTour="add-text" />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} dataTour="scan-network" />}
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} dataTour="zigbee-import" />}
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} dataTour="zwave-import" />}
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} dataTour="proxmox-import" />}
<SidebarItem
icon={Save}
label="Save Canvas"
@@ -294,7 +303,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
icon={LogOut}
label="Logout"
collapsed={collapsed}
onClick={logout}
onClick={() => void handleLogout()}
/>
)}
</div>
@@ -360,12 +369,15 @@ interface SidebarItemProps {
badge?: boolean
accent?: boolean
onClick?: () => void
/** Stable anchor for the Getting Started walkthrough spotlight. */
dataTour?: string
}
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick }: SidebarItemProps) {
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick, dataTour }: SidebarItemProps) {
const btn = (
<button
onClick={onClick}
data-tour={dataTour}
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
active
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
+1 -1
View File
@@ -62,7 +62,7 @@ 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={onAutoLayout}>
<LayoutDashboard size={14} /> Auto Layout
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
<Button data-tour="style" size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
<Palette size={14} /> Style
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
@@ -343,15 +343,58 @@ describe('DetailPanel', () => {
})
describe('Services — add/remove', () => {
it('shows add form when Add is clicked', () => {
it('opens the Add Service modal when Add is clicked', () => {
setupStore({})
render(<DetailPanel onEdit={vi.fn()} />)
// Two "Add" buttons: first = properties, second = services
const addButtons = screen.getAllByText('Add')
fireEvent.click(addButtons[addButtons.length - 1])
expect(screen.getByText('Add Service')).toBeDefined()
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
})
it('renders no service form until the modal is opened', () => {
setupStore({})
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByPlaceholderText('Service name')).toBeNull()
})
it('persists the icon chosen in the modal', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
const addHeaders = screen.getAllByText('Add')
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'plex' } })
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'plex', icon: 'brand:plex' })
})
it('closes the modal after adding', () => {
setupStore({})
render(<DetailPanel onEdit={vi.fn()} />)
const addHeaders = screen.getAllByText('Add')
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
expect(screen.queryByPlaceholderText('Service name')).toBeNull()
})
it('calls updateNode with new service on Add confirm', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
@@ -372,7 +415,7 @@ describe('DetailPanel', () => {
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
})
@@ -430,17 +473,56 @@ describe('DetailPanel', () => {
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
})
it('reorders services on drag and drop', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [
{ port: 80, protocol: 'tcp', service_name: 'A' },
{ port: 81, protocol: 'tcp', service_name: 'B' },
{ port: 82, protocol: 'tcp', service_name: 'C' },
] })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
const rows = screen.getAllByTitle('Drag to reorder').map(
(g) => g.closest('[draggable="true"]') as HTMLElement,
)
// Drag last (C) onto first (A) position
fireEvent.dragStart(rows[2])
fireEvent.dragEnter(rows[0])
fireEvent.drop(rows[0])
expect(updateNode).toHaveBeenCalledOnce()
const [, payload] = updateNode.mock.calls[0]
expect(payload.services.map((s: { service_name: string }) => s.service_name)).toEqual(['C', 'A', 'B'])
})
it('is not draggable with a single service', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'A' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByTitle('Drag to reorder')).toBeNull()
})
})
describe('Services — edit', () => {
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
it('shows edit form pre-filled when pencil is clicked', () => {
it('opens the Edit Service modal pre-filled when pencil is clicked', () => {
setupStore({ services: [{ ...svc, path: '/admin' }] })
render(<DetailPanel onEdit={vi.fn()} />)
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
const editBtn = screen.getByTitle('Edit service')
fireEvent.click(editBtn)
expect(screen.getByText('Edit Service')).toBeDefined()
const nameInput = screen.getByPlaceholderText('Service name') as HTMLInputElement
expect(nameInput.value).toBe('nginx')
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
@@ -474,6 +556,46 @@ describe('DetailPanel', () => {
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
})
it('keeps fields the modal does not own, like category', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [{ ...svc, category: 'web' }] })],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByTitle('Edit service'))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updateNode.mock.calls[0][1].services[0].category).toBe('web')
})
it('saves a brand icon picked while editing', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ services: [svc] })],
selectedNodeId: 'n1',
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByTitle('Edit service'))
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'nginx' } })
fireEvent.click(screen.getByRole('button', { name: 'nginx' }))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updateNode.mock.calls[0][1].services[0].icon).toBe('brand:nginx')
})
it('cancels edit without updating', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
@@ -548,6 +670,25 @@ describe('DetailPanel', () => {
expect(screen.getByText('8080/tcp')).toBeDefined()
})
it('renders a brand icon image when the service carries one', () => {
setupStore({ services: [{ port: 32400, protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }] })
render(<DetailPanel onEdit={vi.fn()} />)
const img = screen.getByAltText('plex') as HTMLImageElement
expect(img.src).toContain('dashboard-icons/svg/plex.svg')
})
it('renders no icon image when the service has no icon', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'nginx' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByRole('img')).toBeNull()
})
it('renders no icon image for an unresolvable legacy icon key', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'nginx', icon: 'bar-chart-2' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByRole('img')).toBeNull()
})
it('renders path label when path is set', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] })
render(<DetailPanel onEdit={vi.fn()} />)
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
@@ -13,6 +13,9 @@ vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
vi.mock('@/api/client', () => ({
authApi: {
logout: vi.fn().mockResolvedValue({}),
},
scanApi: {
trigger: vi.fn().mockResolvedValue({}),
runs: vi.fn().mockResolvedValue({ data: [] }),
@@ -272,10 +275,14 @@ describe('Sidebar', () => {
expect(screen.getByText('Logout')).toBeInTheDocument()
})
it('calls logout when Logout is clicked', () => {
it('ends the backend session before clearing local auth state', async () => {
const { authApi } = await import('@/api/client')
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Logout'))
expect(mockLogout).toHaveBeenCalledOnce()
await waitFor(() => {
expect(authApi.logout).toHaveBeenCalledOnce()
expect(mockLogout).toHaveBeenCalledOnce()
})
})
})
+64 -12
View File
@@ -1,10 +1,21 @@
import { useId } from 'react';
interface LogoProps {
size?: number;
showText?: boolean;
className?: string;
}
/**
* Official Homelable mark — kept in sync with `docs/logo/icon.svg`.
* Wordmark colors follow `docs/logo/logo-md.svg` (Home = #e6edf3, lable = #00d4ff).
*/
export function Logo({ size = 32, showText = true, className = '' }: LogoProps) {
// Colons are legal in a URL fragment but confuse some tooling — strip them.
const uid = useId().replace(/:/g, '');
const bgGlow = `homelable-bg-glow-${uid}`;
const nodeGlow = `homelable-node-glow-${uid}`;
return (
<div className={`flex items-center gap-2 ${className}`}>
<svg
@@ -13,32 +24,73 @@ export function Logo({ size = 32, showText = true, className = '' }: LogoProps)
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
data-testid="logo-mark"
>
<defs>
<radialGradient id={bgGlow} cx="50%" cy="35%" r="55%">
<stop offset="0%" stopColor="#00d4ff" stopOpacity="0.08" />
<stop offset="100%" stopColor="#0d1117" stopOpacity="0" />
</radialGradient>
<filter id={nodeGlow}>
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Background */}
<circle cx="32" cy="32" r="32" fill="#0d1117" />
<circle cx="32" cy="32" r="32" fill={`url(#${bgGlow})`} />
{/* House body */}
<path
d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22"
stroke="#00d4ff"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<rect x="27" y="40" width="10" height="12" rx="1" fill="#0d1117" stroke="#00d4ff" strokeWidth="1" />
<circle cx="32" cy="33" r="3" fill="#00d4ff" />
<circle cx="22" cy="38" r="2" fill="#39d353" />
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
<circle cx="42" cy="38" r="2" fill="#39d353" />
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
<circle cx="32" cy="24" r="2" fill="#a855f7" />
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" strokeWidth="1" opacity="0.7" />
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" strokeWidth="1.5" opacity="0.4" />
{/* Floor line (subtle) */}
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" strokeWidth="0.5" opacity="0.25" />
{/* Door */}
<rect
x="27"
y="40"
width="10"
height="13"
rx="1.5"
fill="#0d1117"
stroke="#00d4ff"
strokeWidth="1"
opacity="0.9"
/>
{/* Network lines (drawn under nodes) */}
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
{/* Center hub */}
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12" />
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter={`url(#${nodeGlow})`} />
{/* Peripheral nodes */}
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
<circle cx="32" cy="23" r="2" fill="#a855f7" filter={`url(#${nodeGlow})`} />
</svg>
{showText && (
<span
className="font-bold tracking-tight"
style={{ fontSize: size * 0.55, fontFamily: 'Inter, sans-serif' }}
>
<span style={{ color: '#00d4ff' }}>Home</span>
<span style={{ color: '#ffffff' }}>lable</span>
<span style={{ color: '#e6edf3' }}>Home</span>
<span style={{ color: '#00d4ff' }}>lable</span>
</span>
)}
</div>
@@ -0,0 +1,33 @@
import { createElement } from 'react'
import { resolveCustomIcon } from '@/utils/nodeIcons'
interface ServiceIconProps {
/** Icon key on the service — a lucide registry key or `brand:<slug>`. */
iconKey?: string
size?: number
className?: string
color?: string
}
/**
* Renders a service's icon, or nothing when the service has no icon (or carries
* a key we can't resolve — e.g. a legacy scanner signature name).
*/
export function ServiceIcon({ iconKey, size = 12, className, color }: ServiceIconProps) {
const resolved = resolveCustomIcon(iconKey)
if (!resolved) return null
if (resolved.kind === 'brand') {
return (
<img
src={resolved.url}
alt={resolved.slug}
width={size}
height={size}
loading="lazy"
className={className}
style={{ width: size, height: size, objectFit: 'contain', flexShrink: 0 }}
/>
)
}
return createElement(resolved.icon, { size, className, color })
}
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Logo } from '../Logo'
describe('Logo', () => {
it('renders the official mark (house + network nodes), not a placeholder icon', () => {
render(<Logo />)
const svg = screen.getByTestId('logo-mark')
// Canonical house outline from docs/logo/icon.svg
expect(svg.querySelector('path')?.getAttribute('d'))
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
// Center hub + 3 peripheral nodes + hub glow + 2 background circles
expect(svg.querySelectorAll('circle').length).toBe(7)
})
it('uses the canonical wordmark colors (Home #e6edf3 / lable #00d4ff)', () => {
render(<Logo showText />)
expect(screen.getByText('Home')).toHaveStyle({ color: '#e6edf3' })
expect(screen.getByText('lable')).toHaveStyle({ color: '#00d4ff' })
})
it('hides the wordmark when showText is false', () => {
render(<Logo showText={false} />)
expect(screen.queryByText('Home')).toBeNull()
expect(screen.getByTestId('logo-mark')).toBeInTheDocument()
})
it('scales the svg to the requested size', () => {
render(<Logo size={64} />)
const svg = screen.getByTestId('logo-mark')
expect(svg.getAttribute('width')).toBe('64')
expect(svg.getAttribute('height')).toBe('64')
})
it('generates unique gradient/filter ids per instance — two logos can coexist', () => {
// Sidebar and Toolbar both render a Logo at the same time; duplicate SVG
// defs ids would make one instance reference the other's gradient.
render(<><Logo /><Logo /></>)
const [first, second] = screen.getAllByTestId('logo-mark')
const idOf = (svg: HTMLElement) => svg.querySelector('radialGradient')!.getAttribute('id')
expect(idOf(first)).not.toBe(idOf(second))
// Each instance must point at its own gradient
const gradientRef = (svg: HTMLElement) =>
svg.querySelectorAll('circle')[1].getAttribute('fill')
expect(gradientRef(first)).toBe(`url(#${idOf(first)!})`)
expect(gradientRef(second)).toBe(`url(#${idOf(second)!})`)
})
it('marks the svg aria-hidden — the adjacent wordmark carries the name', () => {
render(<Logo />)
expect(screen.getByTestId('logo-mark')).toHaveAttribute('aria-hidden', 'true')
})
it('forwards className to the wrapper', () => {
const { container } = render(<Logo className="my-class" />)
expect(container.firstElementChild).toHaveClass('my-class')
})
})
@@ -0,0 +1,46 @@
import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest'
import { render, cleanup, waitFor } from '@testing-library/react'
import { toast } from 'sonner'
import { Toaster } from '../sonner'
beforeAll(() => {
// sonner reads matchMedia for theme detection; jsdom doesn't provide it.
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
})
afterEach(() => cleanup())
describe('Toaster', () => {
it('renders error toasts on a red surface so failures are visible', async () => {
render(<Toaster />)
toast.error('Save failed')
// The style (with the CSS vars) lands on the toast list, mounted on first toast.
const list = await waitFor(() => {
const el = document.querySelector('[data-sonner-toaster]') as HTMLElement | null
expect(el).not.toBeNull()
return el!
})
const style = list.getAttribute('style') ?? ''
expect(style).toContain('--error-bg: #f85149')
expect(style).toContain('--error-text: #ffffff')
// richColors must be on for sonner to apply the per-type surface, and the
// toast itself must be tagged as an error so it picks up --error-bg.
const item = await waitFor(() => {
const el = document.querySelector('[data-sonner-toast]') as HTMLElement | null
expect(el).not.toBeNull()
return el!
})
expect(item.getAttribute('data-rich-colors')).toBe('true')
expect(item.getAttribute('data-type')).toBe('error')
})
})
+18
View File
@@ -11,6 +11,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
richColors
icons={{
success: (
<CircleCheckIcon className="size-4" />
@@ -34,6 +35,23 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
// richColors (below) is required for sonner to honor per-type surfaces.
// Errors get a red surface so failures (e.g. a save while the backend
// is down) stand out instead of reading like a normal toast...
"--error-bg": "#f85149",
"--error-text": "#ffffff",
"--error-border": "#da3633",
// ...while success/info/warning stay on the neutral popover surface so
// the rest of the toast UX is unchanged.
"--success-bg": "var(--popover)",
"--success-text": "var(--popover-foreground)",
"--success-border": "var(--border)",
"--info-bg": "var(--popover)",
"--info-text": "var(--popover-foreground)",
"--info-border": "var(--border)",
"--warning-bg": "var(--popover)",
"--warning-text": "var(--popover-foreground)",
"--warning-border": "var(--border)",
} as React.CSSProperties
}
toastOptions={{
@@ -39,6 +39,7 @@ describe('useStatusPolling', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
authMethod: 'local',
token: 'test-token',
} as ReturnType<typeof useAuthStore>)
@@ -58,6 +59,7 @@ describe('useStatusPolling', () => {
it('does not open WebSocket when not authenticated', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: false,
authMethod: null,
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
@@ -67,12 +69,28 @@ describe('useStatusPolling', () => {
it('does not open WebSocket when token is missing', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
authMethod: 'local',
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(0)
})
it('uses the HttpOnly cookie and sends no credential in OIDC mode', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
authMethod: 'oidc',
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(1)
const ws = MockWebSocket.instances[0]
ws.onopen?.()
expect(ws.send).not.toHaveBeenCalled()
})
it('connects to correct ws:// URL', () => {
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(1)
+10 -6
View File
@@ -25,10 +25,10 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export function useStatusPolling() {
const wsRef = useRef<WebSocket | null>(null)
const { setNodeStatus, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
const { isAuthenticated, token } = useAuthStore()
const { isAuthenticated, authMethod, token } = useAuthStore()
useEffect(() => {
if (STANDALONE || !isAuthenticated || !token) return
if (STANDALONE || !isAuthenticated || (authMethod !== 'oidc' && !token)) return
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const host = window.location.host // includes port when non-standard
@@ -37,9 +37,13 @@ export function useStatusPolling() {
const ws = new WebSocket(url)
wsRef.current = ws
// Send token as first message (not in URL to avoid log/history exposure)
ws.onopen = () => {
ws.send(JSON.stringify({ token }))
// Local mode sends the bearer token as the first message (not in the URL
// where proxies may log it). OIDC mode is authenticated during the upgrade
// with the backend's HttpOnly session cookie, so React sends no credential.
if (token) {
ws.onopen = () => {
ws.send(JSON.stringify({ token }))
}
}
ws.onmessage = (event) => {
@@ -71,5 +75,5 @@ export function useStatusPolling() {
ws.close()
wsRef.current = null
}
}, [isAuthenticated, token, setNodeStatus, notifyScanDeviceFound, setServiceStatuses])
}, [isAuthenticated, authMethod, token, setNodeStatus, notifyScanDeviceFound, setServiceStatuses])
}
@@ -4,7 +4,17 @@ import { useAuthStore } from '@/stores/authStore'
describe('authStore', () => {
beforeEach(() => {
localStorage.clear()
useAuthStore.setState({ token: null, isAuthenticated: false })
sessionStorage.clear()
useAuthStore.setState({
token: null,
isAuthenticated: false,
isInitialized: false,
authMode: null,
authMethod: null,
oidcLoginUrl: null,
displayName: null,
csrfToken: null,
})
})
it('starts unauthenticated', () => {
@@ -18,6 +28,8 @@ describe('authStore', () => {
const { token, isAuthenticated } = useAuthStore.getState()
expect(token).toBe('my-jwt-token')
expect(isAuthenticated).toBe(true)
expect(useAuthStore.getState().authMethod).toBe('local')
expect(useAuthStore.getState().isInitialized).toBe(true)
})
it('logout clears token and isAuthenticated', () => {
@@ -27,4 +39,36 @@ describe('authStore', () => {
expect(token).toBeNull()
expect(isAuthenticated).toBe(false)
})
it('stores OIDC identity and CSRF state without a browser-readable credential', () => {
useAuthStore.getState().setAuthConfig('oidc', '/api/v1/auth/oidc/login')
useAuthStore.getState().authenticate({
subject: 'user-1',
display_name: 'Alice',
auth_method: 'oidc',
issuer: 'https://id.example/application/o/homelable/',
csrf_token: 'csrf-123',
})
const state = useAuthStore.getState()
expect(state.token).toBeNull()
expect(state.isAuthenticated).toBe(true)
expect(state.authMethod).toBe('oidc')
expect(state.displayName).toBe('Alice')
expect(state.csrfToken).toBe('csrf-123')
expect(sessionStorage.getItem('homelable-auth') ?? '').not.toContain('csrf-123')
})
it('keeps a validated local token when applying /auth/me identity', () => {
useAuthStore.getState().login('local-token')
useAuthStore.getState().authenticate({
subject: 'admin',
display_name: 'admin',
auth_method: 'local',
issuer: null,
csrf_token: null,
})
expect(useAuthStore.getState().token).toBe('local-token')
})
})
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useWalkthroughStore } from '../walkthroughStore'
import { readWalkthrough, WALKTHROUGH_VERSION } from '@/utils/walkthrough'
beforeEach(() => {
localStorage.clear()
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
})
describe('walkthroughStore', () => {
it('start activates at step 0', () => {
useWalkthroughStore.getState().start()
const s = useWalkthroughStore.getState()
expect(s.isActive).toBe(true)
expect(s.stepIndex).toBe(0)
})
it('next advances and finishes past the last step', () => {
const store = useWalkthroughStore.getState()
store.start()
store.setTotal(3)
store.next()
expect(useWalkthroughStore.getState().stepIndex).toBe(1)
store.next()
expect(useWalkthroughStore.getState().stepIndex).toBe(2)
// At the last step, next() finishes the tour and stamps it completed.
store.next()
expect(useWalkthroughStore.getState().isActive).toBe(false)
expect(readWalkthrough()).toEqual({ seenVersion: WALKTHROUGH_VERSION, status: 'completed' })
})
it('prev clamps at 0', () => {
const store = useWalkthroughStore.getState()
store.start()
store.setTotal(3)
store.prev()
expect(useWalkthroughStore.getState().stepIndex).toBe(0)
})
it('goTo clamps within [0, total-1]', () => {
const store = useWalkthroughStore.getState()
store.start()
store.setTotal(3)
store.goTo(9)
expect(useWalkthroughStore.getState().stepIndex).toBe(2)
store.goTo(-1)
expect(useWalkthroughStore.getState().stepIndex).toBe(0)
})
it('skip deactivates and stamps skipped', () => {
const store = useWalkthroughStore.getState()
store.start()
store.skip()
expect(useWalkthroughStore.getState().isActive).toBe(false)
expect(readWalkthrough().status).toBe('skipped')
expect(readWalkthrough().seenVersion).toBe(WALKTHROUGH_VERSION)
})
})
+64 -2
View File
@@ -1,9 +1,29 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
export type AuthMode = 'local' | 'oidc'
export type AuthMethod = AuthMode
export interface AuthUser {
subject: string
display_name: string
auth_method: AuthMethod
issuer: string | null
csrf_token: string | null
}
interface AuthState {
token: string | null
isAuthenticated: boolean
isInitialized: boolean
authMode: AuthMode | null
authMethod: AuthMethod | null
oidcLoginUrl: string | null
displayName: string | null
csrfToken: string | null
setAuthConfig: (mode: AuthMode, oidcLoginUrl: string | null) => void
authenticate: (user: AuthUser) => void
finishInitialization: () => void
login: (token: string) => void
logout: () => void
}
@@ -13,14 +33,56 @@ export const useAuthStore = create<AuthState>()(
(set) => ({
token: null,
isAuthenticated: false,
login: (token) => set({ token, isAuthenticated: true }),
logout: () => set({ token: null, isAuthenticated: false }),
isInitialized: false,
authMode: null,
authMethod: null,
oidcLoginUrl: null,
displayName: null,
csrfToken: null,
setAuthConfig: (authMode, oidcLoginUrl) => set({ authMode, oidcLoginUrl }),
authenticate: (user) => set((state) => ({
token: user.auth_method === 'oidc' ? null : state.token,
isAuthenticated: true,
isInitialized: true,
authMethod: user.auth_method,
displayName: user.display_name,
csrfToken: user.csrf_token,
})),
finishInitialization: () => set({ isInitialized: true }),
login: (token) => set({
token,
isAuthenticated: true,
isInitialized: true,
authMethod: 'local',
displayName: null,
csrfToken: null,
}),
logout: () => set({
token: null,
isAuthenticated: false,
isInitialized: true,
authMethod: null,
displayName: null,
csrfToken: null,
}),
}),
{
name: 'homelable-auth',
// sessionStorage: scoped to the tab, cleared on browser close.
// Prevents XSS from other tabs stealing the token via localStorage.
storage: createJSONStorage(() => sessionStorage),
// OIDC credentials stay in HttpOnly cookies. Its CSRF token is deliberately
// memory-only and is recovered from /auth/me after every page load.
partialize: (state) => ({ token: state.token }),
// Ignore authentication flags written by older versions of the store. A
// persisted local token is revalidated by the bootstrap request instead.
merge: (persisted, current) => {
const token = (persisted as { token?: unknown } | null)?.token
return {
...current,
token: typeof token === 'string' ? token : null,
}
},
}
)
)
+48
View File
@@ -0,0 +1,48 @@
import { create } from 'zustand'
import { markWalkthroughSeen } from '@/utils/walkthrough'
/**
* Runtime state of an active Getting Started tour. Persistence of the "already
* seen" flag lives in utils/walkthrough.ts; this store only tracks the live run.
*
* `total` is set by the overlay from the mode-filtered step list, so next()/goTo
* can clamp and finish() fires when the user advances past the last step.
*/
interface WalkthroughStore {
isActive: boolean
stepIndex: number
total: number
setTotal: (n: number) => void
start: () => void
next: () => void
prev: () => void
goTo: (n: number) => void
skip: () => void
finish: () => void
}
export const useWalkthroughStore = create<WalkthroughStore>((set, get) => ({
isActive: false,
stepIndex: 0,
total: 0,
setTotal: (n) => set({ total: n }),
start: () => set({ isActive: true, stepIndex: 0 }),
next: () => {
const { stepIndex, total } = get()
if (stepIndex >= total - 1) {
get().finish()
return
}
set({ stepIndex: stepIndex + 1 })
},
prev: () => set((s) => ({ stepIndex: Math.max(0, s.stepIndex - 1) })),
goTo: (n) => set((s) => ({ stepIndex: Math.max(0, Math.min(n, Math.max(0, s.total - 1))) })),
skip: () => {
markWalkthroughSeen('skipped')
set({ isActive: false })
},
finish: () => {
markWalkthroughSeen('completed')
set({ isActive: false })
},
}))
+3
View File
@@ -34,4 +34,7 @@ export interface YamlNode {
bottomHandles?: number
leftHandles?: number
rightHandles?: number
// Whether port-number labels are shown next to connection points. Only written
// when enabled so the toggle round-trips through export/import (issue #272).
showPortNumbers?: boolean
}
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { decideCanvasLoad, isNewUserCanvas } from '../canvasLoadDecision'
describe('decideCanvasLoad', () => {
it('renders real nodes when the canvas has any', () => {
expect(decideCanvasLoad(true, true)).toBe('real')
// hasNodes wins even if the initialized flag is somehow false
expect(decideCanvasLoad(true, false)).toBe('real')
})
it('keeps an initialized-but-empty canvas empty (user cleared it)', () => {
expect(decideCanvasLoad(false, true)).toBe('empty')
})
it('shows the demo only for a brand-new, uninitialized canvas', () => {
expect(decideCanvasLoad(false, false)).toBe('demo')
})
})
describe('isNewUserCanvas', () => {
it('is true only for the demo mode', () => {
expect(isNewUserCanvas('demo')).toBe(true)
expect(isNewUserCanvas('empty')).toBe(false)
expect(isNewUserCanvas('real')).toBe(false)
})
})
@@ -208,4 +208,12 @@ describe('exportCanvasToYaml', () => {
expect(entry).not.toHaveProperty('topHandles')
expect(entry).not.toHaveProperty('leftHandles')
})
it('exports showPortNumbers only when enabled (issue #272)', () => {
const on = (yaml.load(exportCanvasToYaml([makeNode({ label: 'On', type: 'server', show_port_numbers: true })], [])) as Record<string, unknown>[])[0]
expect(on.showPortNumbers).toBe(true)
const off = (yaml.load(exportCanvasToYaml([makeNode({ label: 'Off', type: 'server', show_port_numbers: false })], [])) as Record<string, unknown>[])[0]
expect(off).not.toHaveProperty('showPortNumbers')
})
})
@@ -368,4 +368,30 @@ describe('parseYamlToCanvas', () => {
expect(edges[0].targetHandle).toBe('top-t')
expect(nodes.find((n) => n.data.label === 'Switch')!.data.bottom_handles).toBe(3)
})
// Regression for issue #272.
it('restores the show-port-numbers toggle from YAML', () => {
const yaml = `
- nodeType: server
label: "Server"
showPortNumbers: true
`
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(nodes.find((n) => n.data.label === 'Server')!.data.show_port_numbers).toBe(true)
})
it('round-trips show-port-numbers through export → import (issue #272)', () => {
const on: Node<NodeData> = {
id: 'on', type: 'server', position: { x: 0, y: 0 },
data: { label: 'On', type: 'server', status: 'online', services: [], show_port_numbers: true },
}
const off: Node<NodeData> = {
id: 'off', type: 'server', position: { x: 0, y: 0 },
data: { label: 'Off', type: 'server', status: 'online', services: [] },
}
const yamlStr = exportCanvasToYaml([on, off], [])
const { nodes } = parseYamlToCanvas(yamlStr, empty, emptyEdges)
expect(nodes.find((n) => n.data.label === 'On')!.data.show_port_numbers).toBe(true)
expect(nodes.find((n) => n.data.label === 'Off')!.data.show_port_numbers).toBeUndefined()
})
})
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { serviceToForm, EMPTY_SERVICE_FORM } from '../serviceForm'
describe('serviceToForm', () => {
it('maps a service onto string form fields', () => {
expect(serviceToForm({ port: 443, protocol: 'udp', service_name: 'svc', path: '/x', icon: 'database' })).toEqual({
service_name: 'svc',
port: '443',
protocol: 'udp',
path: '/x',
icon: 'database',
})
})
it('blanks out absent port and path', () => {
expect(serviceToForm({ protocol: 'tcp', service_name: 'svc' })).toEqual({
service_name: 'svc',
port: '',
protocol: 'tcp',
path: '',
icon: undefined,
})
})
it('keeps a brand icon key intact', () => {
expect(serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }).icon).toBe('brand:plex')
})
it('starts from a blank tcp form', () => {
expect(EMPTY_SERVICE_FORM).toEqual({ service_name: '', port: '', protocol: 'tcp', path: '' })
})
})
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
WALKTHROUGH_VERSION,
readWalkthrough,
writeWalkthrough,
subscribeWalkthrough,
shouldOfferWalkthrough,
markWalkthroughSeen,
resetWalkthrough,
} from '../walkthrough'
beforeEach(() => localStorage.clear())
describe('readWalkthrough', () => {
it('defaults to pending / unseen when nothing is stored', () => {
expect(readWalkthrough()).toEqual({ seenVersion: null, status: 'pending' })
})
it('round-trips written state', () => {
writeWalkthrough({ seenVersion: 3, status: 'completed' })
expect(readWalkthrough()).toEqual({ seenVersion: 3, status: 'completed' })
})
it('falls back to defaults on malformed json', () => {
localStorage.setItem('homelable.walkthrough', '{not json')
expect(readWalkthrough()).toEqual({ seenVersion: null, status: 'pending' })
})
})
describe('shouldOfferWalkthrough', () => {
it('offers a brand-new user (seenVersion null)', () => {
expect(shouldOfferWalkthrough()).toBe(true)
})
it('offers an upgrader whose seen version is behind', () => {
writeWalkthrough({ seenVersion: WALKTHROUGH_VERSION - 1, status: 'completed' })
expect(shouldOfferWalkthrough()).toBe(true)
})
it('stops offering once the current version is stamped', () => {
markWalkthroughSeen('completed')
expect(shouldOfferWalkthrough()).toBe(false)
expect(readWalkthrough().seenVersion).toBe(WALKTHROUGH_VERSION)
})
})
describe('resetWalkthrough', () => {
it('re-offers after a reset', () => {
markWalkthroughSeen('skipped')
expect(shouldOfferWalkthrough()).toBe(false)
resetWalkthrough()
expect(shouldOfferWalkthrough()).toBe(true)
})
})
describe('subscribeWalkthrough', () => {
it('notifies listeners on write and unsubscribes cleanly', () => {
const spy = vi.fn()
const unsub = subscribeWalkthrough(spy)
writeWalkthrough({ seenVersion: 1, status: 'completed' })
expect(spy).toHaveBeenCalledWith({ seenVersion: 1, status: 'completed' })
unsub()
writeWalkthrough({ seenVersion: 2, status: 'skipped' })
expect(spy).toHaveBeenCalledTimes(1)
})
})
+25
View File
@@ -0,0 +1,25 @@
/**
* Decides what to render when a design's canvas loads.
*
* Three cases must stay distinct (issue: clearing a canvas re-showed the demo):
* - `real` → the canvas has saved nodes; render them.
* - `empty` → the canvas is initialized (ever saved / explicitly created) but
* has no nodes; the user intentionally cleared it — keep it empty.
* - `demo` → brand-new, never-initialized canvas; seed the demo so first-time
* users see an example (and, later, the Getting Started walkthrough).
*
* Backend errors are handled by the caller, NOT here — a failed load must show an
* error and must never fall back to `demo` (that would hide the real failure).
*/
export type CanvasLoadMode = 'real' | 'empty' | 'demo'
export function decideCanvasLoad(hasNodes: boolean, initialized: boolean): CanvasLoadMode {
if (hasNodes) return 'real'
if (initialized) return 'empty'
return 'demo'
}
/** A new user is one who lands on the demo canvas — the Getting Started hook. */
export function isNewUserCanvas(mode: CanvasLoadMode): boolean {
return mode === 'demo'
}
+3
View File
@@ -92,6 +92,9 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
// Custom connection-point counts, so imported edges land on real slots.
attachHandleCounts(entry, d)
// Preserve the port-number toggle so it survives a round-trip (issue #272).
if (d.show_port_numbers) entry.showPortNumbers = true
// Parent relationship: if this node has a parentId in React Flow,
// encode it as a 'parent' connection using any virtual edge between them.
if (node.parentId) {
+2
View File
@@ -79,6 +79,8 @@ export function parseYamlToCanvas(
...(yn.bottomHandles ? { bottom_handles: yn.bottomHandles } : {}),
...(yn.leftHandles ? { left_handles: yn.leftHandles } : {}),
...(yn.rightHandles ? { right_handles: yn.rightHandles } : {}),
// Restore the port-number toggle (issue #272).
...(yn.showPortNumbers ? { show_port_numbers: true } : {}),
}
newNodes.push({
+37
View File
@@ -0,0 +1,37 @@
import type { ServiceInfo } from '@/types'
/** String-based shape the service editor works with. */
export interface ServiceFormData {
service_name: string
port: string
protocol: 'tcp' | 'udp'
path: string
icon?: string
}
/** Fields a submitted service form contributes. `undefined` clears the field. */
export interface ServiceSubmitData {
service_name: string
protocol: 'tcp' | 'udp'
port: number | undefined
path: string | undefined
icon: string | undefined
}
export const EMPTY_SERVICE_FORM: ServiceFormData = {
service_name: '',
port: '',
protocol: 'tcp',
path: '',
}
/** Turn a stored service into the string-based form shape. */
export function serviceToForm(svc: ServiceInfo): ServiceFormData {
return {
service_name: svc.service_name,
port: svc.port != null ? String(svc.port) : '',
protocol: svc.protocol,
path: svc.path ?? '',
icon: svc.icon,
}
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Getting Started walkthrough — persisted invite state (localStorage).
*
* Mirrors the autosaveSettings.ts pattern: a `homelable.<name>` key plus a
* `window` CustomEvent for same-tab sync, so App and SettingsModal stay aligned
* without a global store.
*
* `seenVersion` is stamped with WALKTHROUGH_VERSION when the user finishes or
* dismisses the tour. Bump WALKTHROUGH_VERSION whenever new steps ship so
* existing users are re-offered the tour exactly once (they may have missed the
* new features). A brand-new user starts at seenVersion=null and is offered too.
*/
export const WALKTHROUGH_VERSION = 1
export type WalkthroughStatus = 'pending' | 'skipped' | 'completed'
export interface WalkthroughState {
seenVersion: number | null
status: WalkthroughStatus
}
const KEY = 'homelable.walkthrough'
const EVENT = 'homelable:walkthrough-changed'
export const DEFAULT_WALKTHROUGH_STATE: WalkthroughState = { seenVersion: null, status: 'pending' }
function isStatus(v: unknown): v is WalkthroughStatus {
return v === 'pending' || v === 'skipped' || v === 'completed'
}
export function readWalkthrough(): WalkthroughState {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return { ...DEFAULT_WALKTHROUGH_STATE }
const parsed = JSON.parse(raw) as Partial<WalkthroughState>
return {
seenVersion: typeof parsed.seenVersion === 'number' ? parsed.seenVersion : null,
status: isStatus(parsed.status) ? parsed.status : 'pending',
}
} catch {
return { ...DEFAULT_WALKTHROUGH_STATE }
}
}
export function writeWalkthrough(state: WalkthroughState): void {
try {
localStorage.setItem(KEY, JSON.stringify(state))
window.dispatchEvent(new CustomEvent(EVENT, { detail: state }))
} catch {
// ignore quota / SSR
}
}
export function subscribeWalkthrough(listener: (s: WalkthroughState) => void): () => void {
const handler = (e: Event) => listener((e as CustomEvent<WalkthroughState>).detail)
window.addEventListener(EVENT, handler)
return () => window.removeEventListener(EVENT, handler)
}
/** True when the invite should be offered (new user, or upgraded past the last seen version). */
export function shouldOfferWalkthrough(state: WalkthroughState = readWalkthrough()): boolean {
return state.seenVersion !== WALKTHROUGH_VERSION
}
/** Stamp the current version so the invite stops auto-showing until the next bump. */
export function markWalkthroughSeen(status: Exclude<WalkthroughStatus, 'pending'>): void {
writeWalkthrough({ seenVersion: WALKTHROUGH_VERSION, status })
}
/** Clear the stamp so the tour is offered again (used by "Restart" in Settings). */
export function resetWalkthrough(): void {
writeWalkthrough({ ...DEFAULT_WALKTHROUGH_STATE })
}
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react'
import { Compass, X } from 'lucide-react'
import './walkthrough.css'
import { Button } from '@/components/ui/button'
import { useWalkthroughStore } from '@/stores/walkthroughStore'
import {
readWalkthrough,
subscribeWalkthrough,
shouldOfferWalkthrough,
markWalkthroughSeen,
type WalkthroughState,
} from '@/utils/walkthrough'
/**
* First-run invite bubble (bottom-right, above the toaster).
*
* - Not now → hide for this session only (no write), reappears next load
* - Don't show again → persist as skipped, stops offering until the next version
* - Getting started → launch the tour
*/
export function WalkthroughInvite() {
const [state, setState] = useState<WalkthroughState>(readWalkthrough)
useEffect(() => subscribeWalkthrough(setState), [])
// "Not now" dismissal — session-local, not persisted.
const [dismissedThisSession, setDismissedThisSession] = useState(false)
const isActive = useWalkthroughStore((s) => s.isActive)
const start = useWalkthroughStore((s) => s.start)
if (isActive || dismissedThisSession || !shouldOfferWalkthrough(state)) return null
return (
<div className="fixed bottom-20 right-4 z-[90] w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-[#30363d] bg-[#161b22] p-4 shadow-2xl walkthrough-invite-enter">
<button
onClick={() => setDismissedThisSession(true)}
aria-label="Not now"
className="absolute top-2 right-2 text-muted-foreground hover:text-foreground p-1 rounded transition-colors"
>
<X size={14} />
</button>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#00d4ff]/10 text-[#00d4ff]">
<Compass size={18} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">New here? Take the tour</h3>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
A 2-minute walkthrough of scanning, devices, and building your canvas.
</p>
</div>
</div>
<div className="mt-3 flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => markWalkthroughSeen('skipped')}
>
Don't show again
</Button>
<Button
size="sm"
className="h-7 px-3 text-xs bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={start}
>
Getting started
</Button>
</div>
</div>
)
}
@@ -0,0 +1,248 @@
import { useEffect, useMemo, useState, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { ArrowLeft, ArrowRight, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useWalkthroughStore } from '@/stores/walkthroughStore'
import { useWalkthroughActions } from './actions'
import { getSteps, type StepPlacement } from './steps'
import './walkthrough.css'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const CARD_W = 320
const CARD_H_EST = 210
const GAP = 16
const PAD = 6 // spotlight padding around the target
/** Coach-card position from the target rect + requested placement. */
function cardPosition(rect: DOMRect | null, placement: StepPlacement): { left: number; top: number } {
const vw = window.innerWidth
const vh = window.innerHeight
const clampL = (l: number) => Math.max(8, Math.min(l, vw - CARD_W - 8))
const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8))
if (!rect) return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) }
let side = placement
if (side === 'auto') {
side = rect.right + GAP + CARD_W < vw ? 'right' : rect.left - GAP - CARD_W > 0 ? 'left' : 'bottom'
}
switch (side) {
case 'right': return { left: clampL(rect.right + GAP), top: clampT(rect.top) }
case 'left': return { left: clampL(rect.left - GAP - CARD_W), top: clampT(rect.top) }
case 'top': return { left: clampL(rect.left), top: clampT(rect.top - GAP - CARD_H_EST) }
case 'bottom': return { left: clampL(rect.left), top: clampT(rect.bottom + GAP) }
case 'center':
default: return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) }
}
}
/**
* Position the card relative to an OPEN MODAL (the step's real subject).
* Large modals (inventory, scan history at ~90vw) leave no room beside them, so
* the card drops into the empty bottom-right corner instead of covering content.
* Small modals get the card beside them, on whichever side has room.
*/
function dialogCardPosition(rect: DOMRect): { left: number; top: number } {
const vw = window.innerWidth
const vh = window.innerHeight
const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8))
const isLarge = rect.width > vw * 0.6 || rect.height > vh * 0.7
if (isLarge) return { left: vw - CARD_W - 16, top: vh - CARD_H_EST - 16 }
if (rect.left >= CARD_W + GAP * 2) return { left: rect.left - GAP - CARD_W, top: clampT(rect.top) }
if (vw - rect.right >= CARD_W + GAP * 2) return { left: rect.right + GAP, top: clampT(rect.top) }
return { left: Math.max(8, Math.min(rect.left, vw - CARD_W - 8)), top: clampT(rect.bottom + GAP) }
}
export function WalkthroughOverlay() {
const { isActive, stepIndex, total, setTotal, next, prev, skip } = useWalkthroughStore()
const actions = useWalkthroughActions()
const steps = useMemo(() => getSteps(STANDALONE), [])
const step = steps[stepIndex]
// anchorRect: the element the step rings (sidebar/toolbar button). dialogRect:
// any open app modal, always kept lit so it stays readable during modal steps.
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null)
const [dialogRect, setDialogRect] = useState<DOMRect | null>(null)
// Keep the store's total in sync with the mode-filtered step list.
useEffect(() => {
if (isActive) setTotal(steps.length)
}, [isActive, steps.length, setTotal])
// Run the step's action on enter (close leftovers, open the right modal / inject
// demo data). Close everything when the tour ends.
useEffect(() => {
if (!actions) return
if (!isActive) { actions.closeAll(); return }
actions.closeAll()
const key = step?.action as keyof typeof actions | undefined
if (key && typeof actions[key] === 'function') (actions[key] as () => void)()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive, stepIndex, actions])
// Track target rects. Polls each frame because modals mount asynchronously after
// the step action fires, and layout shifts as they animate in.
useEffect(() => {
if (!isActive) return
let raf = 0
const update = () => {
const anchorEl = step?.anchor ? document.querySelector(step.anchor) : null
setAnchorRect(anchorEl ? anchorEl.getBoundingClientRect() : null)
// Any open app modal, excluding our own coach card (inside the overlay).
const dialogEl = Array.from(document.querySelectorAll('[role="dialog"]'))
.find((el) => !el.closest('[data-walkthrough-overlay]'))
setDialogRect(dialogEl ? dialogEl.getBoundingClientRect() : null)
raf = window.requestAnimationFrame(update)
}
update()
window.addEventListener('resize', update)
window.addEventListener('scroll', update, true)
return () => {
window.cancelAnimationFrame(raf)
window.removeEventListener('resize', update)
window.removeEventListener('scroll', update, true)
}
}, [isActive, stepIndex, step?.anchor])
const handleKey = useCallback((e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); next() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
else if (e.key === 'Escape') { e.preventDefault(); skip() }
}, [next, prev, skip])
useEffect(() => {
if (!isActive) return
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [isActive, handleKey])
// Flag the tour so app-modal backdrops drop their own dim/blur (see
// walkthrough.css) — the spotlight then dims consistently everywhere.
useEffect(() => {
if (!isActive) return
document.body.classList.add('walkthrough-running')
return () => document.body.classList.remove('walkthrough-running')
}, [isActive])
if (!isActive || !step) return null
const isLast = stepIndex >= total - 1
// Ring the step's anchor (sidebar/toolbar button) if it has one, else the modal.
const ringRect = anchorRect ?? dialogRect
// The card follows the OPEN MODAL when there is one (that's what the user reads),
// otherwise the step's anchor. Keeps the card off the modal's content.
const pos = dialogRect
? dialogCardPosition(dialogRect)
: cardPosition(anchorRect, step.placement ?? 'auto')
// Cut-outs keep both the ringed control and any open modal lit.
const holes = [anchorRect, dialogRect].filter((r): r is DOMRect => r !== null)
return createPortal(
<div className="fixed inset-0 z-[100]" data-walkthrough-overlay>
{/* Backdrop: dim everything except the cut-out holes (multi-hole SVG mask so
a ringed button AND an open modal can both stay readable). */}
<svg className="pointer-events-none absolute inset-0 h-full w-full">
<defs>
<mask id="wt-spotlight-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
{holes.map((r, i) => (
<rect
key={i}
x={r.left - PAD}
y={r.top - PAD}
width={r.width + PAD * 2}
height={r.height + PAD * 2}
rx="10"
fill="black"
/>
))}
</mask>
</defs>
<rect x="0" y="0" width="100%" height="100%" fill="#010409" fillOpacity="0.72" mask="url(#wt-spotlight-mask)" />
</svg>
{/* Pulsing ring on the primary target */}
{ringRect && (
<div
className="walkthrough-spotlight--pulse pointer-events-none absolute rounded-lg border-2 border-[#00d4ff]"
style={{
left: ringRect.left - PAD,
top: ringRect.top - PAD,
width: ringRect.width + PAD * 2,
height: ringRect.height + PAD * 2,
transition: 'left 0.25s, top 0.25s, width 0.25s, height 0.25s',
}}
/>
)}
{/* Coach card */}
<div
role="dialog"
aria-modal="false"
aria-label="Getting started walkthrough"
className="walkthrough-card-enter absolute pointer-events-auto rounded-xl border border-[#30363d] bg-[#161b22] p-4 shadow-2xl"
style={{ left: pos.left, top: pos.top, width: CARD_W, transition: 'left 0.25s, top 0.25s' }}
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">{step.title}</h3>
<button
onClick={skip}
aria-label="Skip walkthrough"
className="shrink-0 text-muted-foreground hover:text-foreground p-0.5 rounded transition-colors"
>
<X size={14} />
</button>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{step.body}</p>
{step.link && (
<a
href={step.link.href}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-[#00d4ff] hover:underline"
>
{step.link.label} <ArrowRight size={12} />
</a>
)}
<div className="mt-3 flex items-center justify-between gap-2">
{/* Progress dots */}
<div className="flex items-center gap-1.5">
{steps.map((s, i) => (
<span
key={s.id}
className={`h-1.5 rounded-full transition-all ${
i === stepIndex ? 'w-4 bg-[#00d4ff]' : 'w-1.5 bg-[#30363d]'
}`}
/>
))}
</div>
<div className="flex items-center gap-1.5">
<span className="mr-1 text-[10px] font-mono text-muted-foreground">
{stepIndex + 1}/{steps.length}
</span>
<Button
variant="ghost"
size="sm"
aria-label="Previous step"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
onClick={prev}
disabled={stepIndex === 0}
>
<ArrowLeft size={13} />
</Button>
<Button
size="sm"
className="h-7 px-3 text-xs bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={next}
>
{isLast ? 'Finish' : <>Next <ArrowRight size={13} className="ml-1" /></>}
</Button>
</div>
</div>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { renderWithProviders, screen, fireEvent } from '@/test/render'
import { WalkthroughInvite } from '../WalkthroughInvite'
import { useWalkthroughStore } from '@/stores/walkthroughStore'
import { readWalkthrough } from '@/utils/walkthrough'
beforeEach(() => {
localStorage.clear()
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
})
describe('WalkthroughInvite', () => {
it('offers the tour to a new user', () => {
renderWithProviders(<WalkthroughInvite />)
expect(screen.getByText('New here? Take the tour')).toBeInTheDocument()
})
it('is hidden once already seen', () => {
localStorage.setItem('homelable.walkthrough', JSON.stringify({ seenVersion: 1, status: 'completed' }))
renderWithProviders(<WalkthroughInvite />)
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
})
it('"Getting started" launches the tour', () => {
renderWithProviders(<WalkthroughInvite />)
fireEvent.click(screen.getByText('Getting started'))
expect(useWalkthroughStore.getState().isActive).toBe(true)
// Invite hides while the tour is active.
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
})
it('"Don\'t show again" persists and hides', () => {
renderWithProviders(<WalkthroughInvite />)
fireEvent.click(screen.getByText("Don't show again"))
expect(readWalkthrough().status).toBe('skipped')
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
})
it('"Not now" hides without persisting', () => {
renderWithProviders(<WalkthroughInvite />)
fireEvent.click(screen.getByLabelText('Not now'))
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
// Not persisted → would be offered again on next load.
expect(readWalkthrough().seenVersion).toBeNull()
})
})
@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { renderWithProviders, screen, fireEvent, cleanup, act } from '@/test/render'
import { WalkthroughOverlay } from '../WalkthroughOverlay'
import { getSteps } from '../steps'
import { useWalkthroughStore } from '@/stores/walkthroughStore'
const TOTAL = getSteps(false).length
const startTour = () => act(() => { useWalkthroughStore.getState().start() })
beforeEach(() => {
localStorage.clear()
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
})
afterEach(() => cleanup())
describe('WalkthroughOverlay', () => {
it('renders nothing while inactive', () => {
renderWithProviders(<WalkthroughOverlay />)
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
})
it('shows the first step and progress when active', () => {
renderWithProviders(<WalkthroughOverlay />)
startTour()
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
expect(screen.getByText(`1/${TOTAL}`)).toBeInTheDocument()
})
it('advances and rewinds between steps', () => {
renderWithProviders(<WalkthroughOverlay />)
startTour()
fireEvent.click(screen.getByText('Next'))
expect(screen.getByText('Scan your network')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Previous step'))
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
})
it('skip closes the overlay', () => {
renderWithProviders(<WalkthroughOverlay />)
startTour()
fireEvent.click(screen.getByLabelText('Skip walkthrough'))
expect(useWalkthroughStore.getState().isActive).toBe(false)
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { getSteps, STEPS } from '../steps'
describe('getSteps', () => {
it('returns every step in full mode', () => {
expect(getSteps(false)).toHaveLength(STEPS.length)
})
it('drops backend-only (mode:full) steps in standalone', () => {
const standalone = getSteps(true)
expect(standalone.every((s) => s.mode !== 'full')).toBe(true)
expect(standalone.length).toBeLessThan(STEPS.length)
const ids = standalone.map((s) => s.id)
// Canvas-only steps survive; backend-only steps are filtered out.
expect(ids).toEqual(expect.arrayContaining(['welcome', 'nodes', 'grouping', 'style', 'end']))
expect(ids).not.toContain('scan')
expect(ids).not.toContain('scan-history')
expect(ids).not.toContain('inventory')
expect(ids).not.toContain('imports')
})
it('ends on a step with a GitHub link (the full-mode recap in standalone)', () => {
const last = getSteps(true).at(-1)
expect(last?.id).toBe('end')
expect(last?.link?.href).toContain('github.com')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { createContext, useContext } from 'react'
/**
* Bridge between tour steps and the App's modal/canvas controls. App builds the
* concrete implementation from its useState setters and provides it here; the
* overlay resolves each step's `action` string to a function on this object.
*
* `closeAll` is called before every step's action so leftover modals from the
* previous step are dismissed.
*
* Grows as steps land (openInventoryDemo, editDemoNode, openStyle, …).
*/
export interface WalkthroughActionApi {
closeAll: () => void
openScanConfig: () => void
openScanHistoryDemo: () => void
openInventoryDemo: () => void
editFirstNode: () => void
selectTwoNodes: () => void
openStyle: () => void
openZigbeeImport: () => void
}
const WalkthroughActionsContext = createContext<WalkthroughActionApi | null>(null)
export const WalkthroughActionsProvider = WalkthroughActionsContext.Provider
export function useWalkthroughActions(): WalkthroughActionApi | null {
return useContext(WalkthroughActionsContext)
}
+65
View File
@@ -0,0 +1,65 @@
import type { ScanRun } from '@/components/modals/ScanHistoryModal'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
/**
* Canned data injected into the real modals during the tour, so the demo steps
* look alive on a fresh install without hitting the backend. Never persisted.
*/
export const DEMO_SCAN_RUNS: ScanRun[] = [
{
id: 'tour-scan-running',
status: 'running',
kind: 'ip',
ranges: ['192.168.1.0/24'],
devices_found: 12,
// 8s ago so the live "Duration" reads a realistic elapsed time.
started_at: new Date(Date.now() - 8000).toISOString(),
finished_at: null,
error: null,
},
{
id: 'tour-scan-done',
status: 'done',
kind: 'ip',
ranges: ['192.168.1.0/24'],
devices_found: 9,
started_at: new Date(Date.now() - 3600_000).toISOString(),
finished_at: new Date(Date.now() - 3560_000).toISOString(),
error: null,
},
]
export const DEMO_PENDING_DEVICES: PendingDevice[] = [
{
id: 'tour-dev-1',
ip: '192.168.1.20',
mac: 'AA:BB:CC:DD:EE:20',
hostname: 'synology-nas',
os: 'DSM 7.2',
services: [{ port: 5000, protocol: 'tcp', service_name: 'Synology DSM', category: 'nas' }],
suggested_type: 'nas',
status: 'pending',
discovery_source: 'arp',
friendly_name: 'Synology NAS',
vendor: 'Synology',
model: 'DS920+',
discovered_at: new Date(Date.now() - 120_000).toISOString(),
canvas_count: 0,
},
{
id: 'tour-dev-2',
ip: '192.168.1.30',
mac: 'AA:BB:CC:DD:EE:30',
hostname: 'pi-hole',
os: null,
services: [{ port: 80, protocol: 'tcp', service_name: 'Pi-hole', category: 'network' }],
suggested_type: 'generic',
status: 'pending',
discovery_source: 'arp',
friendly_name: 'Pi-hole',
vendor: 'Raspberry Pi',
model: null,
discovered_at: new Date(Date.now() - 90_000).toISOString(),
canvas_count: 0,
},
]
+121
View File
@@ -0,0 +1,121 @@
/**
* Declarative Getting Started steps.
*
* `anchor` is a CSS selector for the element to ring/spotlight (undefined → the
* overlay lights any open modal, or centers the card). `action` is a key resolved
* against the walkthrough actions context on step enter (opens a modal, injects
* demo data, selects nodes, …). `mode: 'full'` marks a backend-only step that is
* filtered out of the standalone/public build. `link` renders a link in the card
* (used for the closing "questions?" step).
*/
export type StepPlacement = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'auto'
export interface TourStep {
id: string
title: string
body: string
anchor?: string
placement?: StepPlacement
action?: string
mode?: 'full' | 'all'
link?: { label: string; href: string }
}
export const STEPS: TourStep[] = [
{
id: 'welcome',
title: 'Welcome to Homelable',
body: 'Take a quick tour of the main features : scanning your network, managing devices, and building your canvas. You can skip anytime and restart later from Settings.',
placement: 'center',
mode: 'all',
},
{
id: 'scan',
title: 'Scan your network',
body: 'Start here: run an IP scan to auto-discover devices on your network. Pick the ranges and ports, then launch, discovered devices land in your inventory for review.',
anchor: '[data-tour="scan-network"]',
placement: 'right',
action: 'openScanConfig',
mode: 'full',
},
{
id: 'scan-history',
title: 'Follow scan progress',
body: 'Scan History shows every run live. A running scan updates in place with elapsed time and device count. You can stop it here too. (This is example data.)',
placement: 'left',
action: 'openScanHistoryDemo',
mode: 'full',
},
{
id: 'inventory',
title: 'Review discovered devices',
body: 'Discovered devices wait in your inventory. For each one you can approve it onto the canvas, hide it, or delete it, with detected services and vendor shown. (Example devices.)',
placement: 'left',
action: 'openInventoryDemo',
mode: 'full',
},
{
id: 'nodes',
title: 'Your devices on the canvas',
body: 'Approved devices become nodes on the canvas. Each shows live status, IP, hostname and running services. Drag to arrange them and draw links between them.',
anchor: '.react-flow__node',
placement: 'auto',
mode: 'all',
},
{
id: 'node-edit',
title: 'Edit a device',
body: 'Double-click any node to edit it: name, type, IP, the status-check method, appearance, and its parent (e.g. a VM inside a Proxmox host). Changes are yours until you Save.',
placement: 'left',
action: 'editFirstNode',
mode: 'all',
},
{
id: 'text-zone',
title: 'Annotate with text & zones',
body: 'Add free Text labels and Zones (colored rectangles) to document your layout to group a rack, mark a VLAN, or leave a note. Both live under the canvas actions.',
anchor: '[data-tour="add-zone"]',
placement: 'right',
mode: 'all',
},
{
id: 'grouping',
title: 'Group related devices',
body: 'Select multiple nodes, then Create Group to box them together and drag them as one. Perfect for a rack, a site, or a subnet.',
anchor: '[data-tour="create-group"]',
placement: 'left',
action: 'selectTwoNodes',
mode: 'all',
},
{
id: 'style',
title: 'Make it yours',
body: 'Switch the canvas theme from Style, or fine-tune colors, borders and fonts per node type. Your homelab, your look.',
anchor: '[data-tour="style"]',
placement: 'bottom',
action: 'openStyle',
mode: 'all',
},
{
id: 'imports',
title: 'Import from your stack',
body: 'Already running Zigbee2MQTT, Z-Wave JS, or Proxmox? Import devices directly from them instead of scanning. One click brings the whole topology in.',
anchor: '[data-tour="zigbee-import"]',
placement: 'right',
action: 'openZigbeeImport',
mode: 'full',
},
{
id: 'end',
title: "You're all set",
body: "That's the tour! Build your map, save it, and share a read-only view. In full mode you also get network scanning, a device inventory, and Zigbee / Z-Wave / Proxmox import. Have a question or an idea?",
placement: 'center',
mode: 'all',
link: { label: 'Ask on GitHub', href: 'https://github.com/Pouzor/homelable/issues' },
},
]
/** Steps for the current build — backend-only steps are dropped in standalone. */
export function getSteps(standalone: boolean): TourStep[] {
return STEPS.filter((s) => s.mode !== 'full' || !standalone)
}
+39
View File
@@ -0,0 +1,39 @@
/* Getting Started walkthrough animations. Kept in a shared stylesheet (imported
by both the invite bubble and the overlay) so keyframes exist regardless of
which component is mounted. */
@keyframes wt-invite-enter {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: none; }
}
.walkthrough-invite-enter { animation: wt-invite-enter 0.25s ease-out; }
@keyframes wt-card-enter {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.walkthrough-card-enter { animation: wt-card-enter 0.2s ease-out; }
/* The dim surround is drawn by the overlay's SVG mask; this is just the pulsing
cyan ring around the primary target (outline so it sits outside the border). */
.walkthrough-spotlight--pulse { animation: wt-pulse 1.8s ease-in-out infinite; }
@keyframes wt-pulse {
0%, 100% { outline: 0px solid rgba(0, 212, 255, 0.55); }
50% { outline: 7px solid rgba(0, 212, 255, 0); }
}
/* While the tour runs, app modals must not add their own dim/blur — the overlay's
SVG spotlight is the single source of dimming, so a ringed sidebar/toolbar
control behind an open modal stays crisp instead of being double-dimmed and
blurred by the modal's own backdrop. */
body.walkthrough-running [data-slot="dialog-overlay"] {
background: transparent !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
@media (prefers-reduced-motion: reduce) {
.walkthrough-invite-enter,
.walkthrough-card-enter,
.walkthrough-spotlight--pulse { animation: none; }
}
+48 -2
View File
@@ -1,10 +1,27 @@
import json
from urllib.parse import quote
from mcp.server import Server
from mcp.types import Tool, TextContent
from .backend_client import backend
NODE_TYPES = ["isp", "router", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap", "generic"]
# Kept in sync manually with frontend/src/types/index.ts NodeType (device types only —
# group/groupRect/text are canvas annotations created via dedicated UI actions, not create_node).
# Sync is enforced automatically by test_node_types_in_sync_with_frontend (mcp/tests/test_node_types_sync.py).
NODE_TYPES = [
"isp", "router", "firewall", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap",
"camera", "printer", "computer", "laptop", "mobile", "cpl", "docker_host", "docker_container",
"generic", "zigbee_coordinator", "zigbee_router", "zigbee_enddevice",
"zwave_coordinator", "zwave_router", "zwave_enddevice", "grid", "ups", "battery", "generator",
"solar_panel", "inverter", "circuit_breaker", "contactor", "electrical_switch", "socket",
"light", "meter", "transformer", "load",
]
# Kept in sync manually with frontend/src/types/index.ts EdgeType.
# Sync is enforced automatically by test_edge_types_in_sync_with_frontend (mcp/tests/test_edge_types_sync.py).
EDGE_TYPES = [
"ethernet", "wifi", "iot", "vlan", "virtual", "cluster", "fibre", "electrical",
]
# Shared field schemas mirroring backend NodeBase / NodeUpdate (backend/app/schemas/nodes.py).
# create_node and update_node both expose these so the MCP is symmetric with what the
@@ -92,7 +109,7 @@ def _build_tools() -> list[Tool]:
"properties": {
"source": {"type": "string"},
"target": {"type": "string"},
"type": {"type": "string", "enum": ["ethernet", "wifi", "iot", "vlan", "virtual"], "default": "ethernet"},
"type": {"type": "string", "enum": EDGE_TYPES, "default": "ethernet"},
"label": {"type": "string"},
**_DESIGN_ID_FIELD,
},
@@ -131,6 +148,17 @@ def _build_tools() -> list[Tool]:
"type": "object",
"properties": {},
}),
Tool(name="list_node_summaries", description="List all nodes with only id/label/type/status — a lighter, lower-token alternative to list_nodes or get_canvas for when full node detail (ip, services, notes, hardware, properties, ...) isn't needed.", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="get_node", description="Get node(s) by id or by label. Provide exactly one of the two. Lookup by 'id' returns a single full node object. Lookup by 'label' is a case-insensitive substring match and returns a list of full node objects, since labels are not unique.", inputSchema={
"type": "object",
"properties": {
"id": {"type": "string", "description": "Node id. Returns a single node object."},
"label": {"type": "string", "description": "Case-insensitive substring match on label. Returns a list of matching node objects."},
},
}),
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
"type": "object",
"properties": {},
@@ -213,6 +241,11 @@ def _slim_canvas(raw: dict) -> dict:
}
def _slim_node_summary(n: dict) -> dict:
"""Only the fields needed to identify a node and reference it in later calls."""
return {"id": n.get("id"), "label": n.get("label"), "type": n.get("type"), "status": n.get("status")}
async def _dispatch(name: str, args: dict) -> dict:
if name == "create_node":
return await backend.post("/api/v1/nodes", args)
@@ -250,6 +283,19 @@ async def _dispatch(name: str, args: dict) -> dict:
if name == "list_nodes":
return await backend.get("/api/v1/nodes")
if name == "list_node_summaries":
nodes = await backend.get("/api/v1/nodes")
return [_slim_node_summary(n) for n in nodes]
if name == "get_node":
node_id = args.get("id")
label = args.get("label")
if node_id:
return await backend.get(f"/api/v1/nodes/{node_id}")
if label:
return await backend.get(f"/api/v1/nodes?label={quote(label)}")
raise ValueError("get_node requires either 'id' or 'label'")
if name == "list_pending_devices":
# Backend /scan/pending returns the whole inventory: approved rows stay
# listed so the frontend can show a canvas-presence badge. This tool
+11 -1
View File
@@ -1,5 +1,15 @@
mcp[cli]>=1.0
# Upper bound is load-bearing: mcp 2.0 dropped the low-level `Server.list_resources`
# / `Server.list_tools` decorator API that app/resources.py and app/tools.py use.
# Removing the bound breaks both the tests and the Docker image at import time.
mcp[cli]>=1.26,<2
httpx>=0.27
fastapi>=0.115
uvicorn[standard]>=0.30
pydantic-settings>=2.0
# Test-only, kept here for the same reason backend/requirements.txt carries its
# own: CI installs a single requirements file then runs pytest.
# pytest-anyio provides the `anyio` marker and parametrises asyncio + trio.
pytest>=9.0
pytest-anyio>=0.0.0
trio>=0.27
+26
View File
@@ -0,0 +1,26 @@
import re
from pathlib import Path
from app.tools import EDGE_TYPES
FRONTEND_TYPES_FILE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "types" / "index.ts"
def _frontend_edge_types() -> set[str]:
src = FRONTEND_TYPES_FILE.read_text()
match = re.search(r"export type EdgeType =([^\n]+)", src)
assert match, (
"Could not locate the EdgeType union in frontend/src/types/index.ts — "
"update this parser if the file's shape changed."
)
return set(re.findall(r"'([^']+)'", match.group(1)))
def test_edge_types_in_sync_with_frontend():
frontend_types = _frontend_edge_types()
mcp_types = set(EDGE_TYPES)
assert mcp_types == frontend_types, (
"mcp/app/tools.py EDGE_TYPES is out of sync with frontend/src/types/index.ts EdgeType.\n"
f"Missing from EDGE_TYPES: {sorted(frontend_types - mcp_types)}\n"
f"Extra in EDGE_TYPES (no longer an edge type?): {sorted(mcp_types - frontend_types)}"
)
+30
View File
@@ -0,0 +1,30 @@
import re
from pathlib import Path
from app.tools import NODE_TYPES
FRONTEND_TYPES_FILE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "types" / "index.ts"
# Canvas annotations, not devices — created via dedicated UI actions (grouping a
# selection, "Add Zone", "Add Text"), never through create_node/approve_device.
ANNOTATION_TYPES = {"group", "groupRect", "text"}
def _frontend_node_types() -> set[str]:
src = FRONTEND_TYPES_FILE.read_text()
match = re.search(r"export type NodeType =\n((?:\s*\|\s*'[^']+'\n)+)", src)
assert match, (
"Could not locate the NodeType union in frontend/src/types/index.ts — "
"update this parser if the file's shape changed."
)
return set(re.findall(r"'([^']+)'", match.group(1)))
def test_node_types_in_sync_with_frontend():
frontend_types = _frontend_node_types() - ANNOTATION_TYPES
mcp_types = set(NODE_TYPES)
assert mcp_types == frontend_types, (
"mcp/app/tools.py NODE_TYPES is out of sync with frontend/src/types/index.ts NodeType.\n"
f"Missing from NODE_TYPES: {sorted(frontend_types - mcp_types)}\n"
f"Extra in NODE_TYPES (no longer a device type?): {sorted(mcp_types - frontend_types)}"
)
+86
View File
@@ -65,6 +65,16 @@ async def test_update_node_properties(mock_backend):
})
@pytest.mark.anyio
@pytest.mark.parametrize("node_type", ["firewall", "docker_container", "ups", "camera", "zigbee_router"])
async def test_create_node_accepts_previously_rejected_types(mock_backend, node_type):
# These types were missing from the old, stale NODE_TYPES enum and were
# rejected by the MCP schema even though the backend always accepted them.
args = {"type": node_type, "label": "Device"}
await _dispatch("create_node", dict(args))
mock_backend.post.assert_called_once_with("/api/v1/nodes", args)
@pytest.mark.anyio
async def test_delete_node(mock_backend):
await _dispatch("delete_node", {"id": "42"})
@@ -77,6 +87,16 @@ async def test_create_edge(mock_backend):
mock_backend.post.assert_called_once_with("/api/v1/edges", {"source": "1", "target": "2", "type": "ethernet"})
@pytest.mark.anyio
@pytest.mark.parametrize("edge_type", ["cluster", "fibre", "electrical"])
async def test_create_edge_accepts_previously_rejected_types(mock_backend, edge_type):
# These types were missing from the old, stale edge type enum and were
# rejected by the MCP schema even though the backend always accepted them.
args = {"source": "1", "target": "2", "type": edge_type}
await _dispatch("create_edge", dict(args))
mock_backend.post.assert_called_once_with("/api/v1/edges", args)
@pytest.mark.anyio
async def test_create_node_with_design_id(mock_backend):
# design_id is forwarded to the backend, which attaches the node to that canvas.
@@ -205,6 +225,23 @@ def test_create_node_schema_exposes_full_node_fields():
assert "enum" in props["type"]
@pytest.mark.parametrize("tool_name", ["create_node", "update_node", "approve_device"])
def test_node_type_enum_excludes_canvas_annotations(tool_name):
# group/groupRect/text are canvas annotations created via dedicated UI actions
# (grouping a selection, "Add Zone", "Add Text") — not real devices, so they
# must never be creatable/settable through the device-oriented node tools.
type_enum = set(_tool_schema(tool_name)["type"]["enum"])
assert type_enum.isdisjoint({"group", "groupRect", "text"})
assert "firewall" in type_enum
def test_create_edge_schema_exposes_full_edge_type_enum():
props = _tool_schema("create_edge")
type_enum = set(props["type"]["enum"])
assert {"cluster", "fibre", "electrical"} <= type_enum
assert props["type"]["default"] == "ethernet"
def test_update_node_schema_exposes_full_node_fields():
props = _tool_schema("update_node")
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
@@ -233,6 +270,55 @@ async def test_list_nodes(mock_backend):
assert result == [{"id": "1", "label": "Freebox"}]
@pytest.mark.anyio
async def test_list_node_summaries(mock_backend):
mock_backend.get = AsyncMock(return_value=[
{
"id": "1", "label": "Freebox", "type": "router", "status": "online",
"ip": "192.168.1.1", "notes": "ISP box", "properties": [{"name": "rack", "value": "A1"}],
},
])
result = await _dispatch("list_node_summaries", {})
mock_backend.get.assert_called_once_with("/api/v1/nodes")
assert result == [{"id": "1", "label": "Freebox", "type": "router", "status": "online"}]
@pytest.mark.anyio
async def test_get_node_by_id(mock_backend):
mock_backend.get = AsyncMock(return_value={"id": "42", "label": "pve1", "type": "proxmox"})
result = await _dispatch("get_node", {"id": "42"})
mock_backend.get.assert_called_once_with("/api/v1/nodes/42")
assert result == {"id": "42", "label": "pve1", "type": "proxmox"}
@pytest.mark.anyio
async def test_get_node_by_label(mock_backend):
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "pve1"}, {"id": "2", "label": "pve2"}])
result = await _dispatch("get_node", {"label": "pve"})
mock_backend.get.assert_called_once_with("/api/v1/nodes?label=pve")
assert result == [{"id": "1", "label": "pve1"}, {"id": "2", "label": "pve2"}]
@pytest.mark.anyio
async def test_get_node_by_label_url_encodes(mock_backend):
mock_backend.get = AsyncMock(return_value=[])
await _dispatch("get_node", {"label": "living room ap"})
mock_backend.get.assert_called_once_with("/api/v1/nodes?label=living%20room%20ap")
@pytest.mark.anyio
async def test_get_node_id_takes_precedence_over_label(mock_backend):
mock_backend.get = AsyncMock(return_value={"id": "42"})
await _dispatch("get_node", {"id": "42", "label": "pve"})
mock_backend.get.assert_called_once_with("/api/v1/nodes/42")
@pytest.mark.anyio
async def test_get_node_requires_id_or_label():
with pytest.raises(ValueError, match="requires either"):
await _dispatch("get_node", {})
@pytest.mark.anyio
async def test_list_pending_devices(mock_backend):
mock_backend.get = AsyncMock(return_value=[{"id": "p1", "ip": "192.168.1.50"}])