Part of #15's fourth piece: on-demand active investigation of a
single unknown device, admin-triggered from the dashboard. mDNS
resolve, targeted SSDP/UPnP query (many smart-home devices announce a
friendlyName/manufacturer this way), curated port scan, HTTP
title/server grab on anything open. Runs on the CT122 host for the
same multicast-needs-real-network-access reason discover-devices.sh
does. Not wired into the API yet -- that's next.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
First test run hit a bug in the test itself: Playwright's hasText
filter does substring matching, so IP 192.168.1.1 matched
192.168.1.10, 192.168.1.100, 192.168.1.171, etc -- flaky/wrong row
selection. Added a data-ip attribute to each row for exact targeting
instead of relying on text content.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Conflated CREATE TABLE/INDEX's IF NOT EXISTS support with ALTER
TABLE ADD COLUMN, which SQLite has never supported -- syntax error,
not a version issue (confirmed on 3.49.2). Check pragma table_info
for the column first instead. Verified against both a fresh DB and
one simulating the existing pre-migration production schema, and
confirmed idempotent on a second open.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Named import of a CommonJS module's export crashed the whole process
at boot (SyntaxError, not caught by tsc since it's a runtime module
resolution behavior, not a type error). Verified by actually running
the built output with node this time instead of trusting tsc alone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- OUI: mac-oui-lookup package resolves vendor from the MAC prefix
(computed on read, no storage needed). Already correctly identifies
the LXC host prefix as "Proxmox Server Solutions GmbH" and several
"unknown" devices as "Amazon Technologies Inc." -- likely the Echo
Dots / Ring gear.
- mDNS: discover-devices.sh now runs avahi-resolve per discovered IP
(parallel, bounded 2s timeout per host so one non-mDNS device can't
stall the run), stored in a new devices.mdns_hostname column.
- Manual labels: new device_labels table keyed by MAC (survives DHCP
IP changes), PUT/DELETE /api/devices/:mac/label, inline-editable
Name cell in the dashboard. Deliberately separate from vendor/mDNS
info -- those are shown as an italic *hint* for unlabeled devices,
not treated as "known" until the admin actually confirms one.
- Fixed the Name column's sort comparator to match what's rendered
(name, else vendor/mDNS hint) instead of just the raw name field --
caught while reasoning through what the existing sort test would
actually need to assert once hints appear in the column.
Part of #15 (OUI/mDNS/manual labels done; on-demand deep-check next).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Click a header to sort by it (ascending), click again to reverse.
Status defaults to known-first (its display string sorts that way
naturally, no special-casing needed). IP sorts numerically by octet,
not lexically. Ties fall back to IP order so the table doesn't
reshuffle mid-poll for devices sharing a sort value (e.g. many
unnamed unknowns).
Added a Playwright test verifying IP asc/desc and Name asc against
real rendered data.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Added: wrong-password rejection (local + OIDC), session survives a
page reload, and API-level checks that /api/hosts, /api/devices,
/api/auth/me all reject unauthenticated requests regardless of what
the UI does.
No new app bugs found this round -- one test assertion was itself
wrong (expected no session cookie on failed login; @fastify/session
issues an anonymous cookie on any response by design, that's normal).
Fixed to assert the property that actually matters: the cookie grants
no access. 9/9 tests green across 4 consecutive full-suite runs with
parallel workers, no flakiness.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fastify only sees plain HTTP -- TLS terminates at NPM/Cloudflare
before reaching this process. Building the callback's currentUrl from
req.headers.host with a hardcoded "http://" sent
redirect_uri=http://monitor.jerodrigged.com/... during the token
exchange, which Authentik rejects (logged as generic "invalid_client"
to the client, but its own event log said plainly: "Invalid redirect
URI used by provider"). Fixed by reusing the known-correct
redirectUri's origin and only taking the query string from the actual
request, instead of trying to infer scheme from headers.
Also fixes the Playwright OIDC test's selectors (Authentik's password
field has no <label> association -- placeholder text, not getByLabel).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix: OIDC_ISSUER_URL used Authentik's LAN IP (192.168.1.208:9443).
Authentik's discovery doc echoes back whichever host you query it
through, so that LAN IP got baked into authorization_endpoint -- the
URL the *browser* gets redirected to. Anyone off the LAN got sent to
an address they couldn't reach. Authentik was already publicly
exposed at auth.jerodrigged.com (pre-existing NPM proxy host); switched
to that, which also has a real cert so OIDC_ALLOW_INSECURE_TLS could
go back to false. Reported as "signed in via Authentik, redirected to
the local IP, failed."
fix: frontend's request() helper always sent Content-Type:
application/json, even for logout's bodyless POST. Fastify's default
JSON parser rejects an empty body under that content-type (400) --
sign-out silently failed to log the user out. curl-based testing
missed this because curl doesn't set that header without -d. Caught
immediately by the new Playwright local-login test.
e2e/: Playwright suite for local auth and OIDC login. OIDC test uses
a dedicated Authentik test user (blueprint-provisioned, never a real
personal login) so the whole flow can run unattended and repeatedly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Single-file bind mounts pin the container to that file's inode at
mount time. sed -i and most editors write-then-rename (atomic write),
which swaps in a new inode at the same path -- the container kept
reading the orphaned original and never saw edits, silently breaking
the hot-reload from the previous commit. Caught by actually testing
the reload live instead of trusting the code. Directory mounts
resolve paths dynamically and don't have this problem.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Checks the file's mtime on each poll cycle (already running every
30s) rather than adding a separate file-watcher or admin UI. Proxmox
hosts already needed no config (auto-discovered every poll); this
covers the two lists that did. A parse failure logs and keeps the
previous config running instead of crashing the poller.
Closes#14.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/nodes/{node}/status nests memory/rootfs objects; the LXC listing
endpoint uses flat mem/maxmem/disk/maxdisk. Code assumed the LXC
shape for both, so the Proxmox host's own memPct/diskPct were NaN ->
serialized as null the whole time. Found while checking sparkline
history data looked wrong for the host card specifically.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
24h of snapshots were already being retained but never read. Adds a
windowed query (last ~40 samples/host, one query total via
ROW_NUMBER() OVER PARTITION BY, not N+1) embedded in the existing
/api/hosts response, rendered as small hand-rolled SVG sparklines
(cpu/mem/disk overlaid) -- no charting library needed at this scale.
Closes#10.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cloudflare tunnel route -> NPM -> dashboard, Let's Encrypt cert via
NPM's API, both public and LAN OIDC redirect URIs registered in
Authentik. Hit and fixed a Flexible-SSL redirect loop (ssl_forced
must stay false since Cloudflare terminates TLS at the edge and talks
plain HTTP to the origin) -- documented clearly so it doesn't get
"fixed" by accident later.
Closes#13.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Local auth stays the primary/always-available login (don't want to
lock out the saved admin password) — OIDC is additive, shown as a
second button when OIDC_ENABLED=true. Uses openid-client v6 with PKCE.
Authentik-side provider was set up via an authentik blueprint (its own
declarative automation, see docs/oidc-setup.md) rather than touching
any existing admin credentials.
Closes#12.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Runs as a host-level systemd timer on CT122 (scripts/discover-devices.sh)
rather than inside the api container, since real ARP entries live in the
host's network namespace, not Docker's bridge network. See
docs/device-discovery.md for the full writeup, including why literal
passive-only ARP reading was dropped (near-empty result in practice).
API reads the resulting JSON file each poll cycle, cross-references
config/hosts.yaml's knownDevices list by IP, and serves /api/devices.
Dashboard gets a new "Network Devices" table.
Closes#9.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends monitoring to the two bare-metal boxes Proxmox can't see.
Uses a dedicated ed25519 key with a forced authorized_keys command
(see docs/ssh-collector-key-setup.md) so a leaked key can only ever
run the fixed read-only stats script, never arbitrary commands.
CPU is approximated from 1-min load average / core count (a true
utilization % would need two /proc/stat samples).
Closes#8.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cookie was silently never set because NODE_ENV=production forced
secure=true while the app is served over plain HTTP on the LAN (TLS
terminates at a reverse proxy later, not here). Add explicit
COOKIE_SECURE env var, default false.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Vertical slice for Phase 1 (v1-dashboard milestone): Proxmox collector,
SQLite storage, local auth, and a dashboard UI showing host/container
status cards. Config-driven collector registry so future data sources
(SSH-based hosts, Zabbix, network discovery) plug in without rewiring.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>