The previous fix made the nav card and content column exactly equal
height, but the two boxes' TOP EDGES were already aligned all along —
what actually looked "off" was that the nav's first item text sat
~12px lower than the content heading/Save button, because Tabler's
default list-group-item padding (1.25rem) is sized for a standalone
list card, not a compact sidebar nav.
Verified with the same pixel-measurement technique as the previous
fix, against the real markup rendered in a browser: before, the nav's
first-item text center sat at y=95.5 while the content heading/button
center sat at y=84 (both card tops already matched at y=64). Tightening
this list-group's own item padding to 0.5rem (scoped via its own
--tblr-list-group-item-padding-y CSS variable, not a global override)
brought the nav text center to y=83.5 — 0.5px off target, imperceptible.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The nav was a bare list-group with no card wrapper, so it only stood
as tall as its own 6 items while the content column (full of cards)
ran on much longer below it — the two looked like they were floating
at different levels instead of one cohesive layout.
Wrapped the nav in a card (list-group-flush inside it, matching the
visual weight of the content's own cards) and stretched it to the row
's full height. That alone left a stray 16px gap between the two
columns' bottoms — traced to the nav column's mb-3 (meant for spacing
when the columns stack on mobile) counting against the flex-stretch
calculation even at desktop widths, where the columns sit side by
side and don't need it. Changed it to mb-3 mb-md-0, which is what
actually eliminated the gap.
Verified directly in a browser against the compiled markup: measured
the two columns' rendered heights before and after — before, a
16px/only-cosmetic h-100 attempt left a residual gap (traced to the
mb-3 collision above); after fixing that, both columns are exactly
byte-for-byte equal height with matching bottom edges at desktop
width, confirmed via getBoundingClientRect rather than by eye.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A failing backup run is visible now, but a guest with no backup job
covering it in the first place was still a silent gap. Rather than
depending on Proxmox's /cluster/backup-info/not-backed-up-guests
endpoint (only exists on newer PVE versions), this derives coverage
from data already fetched: a guest counts as covered if any enabled
job either lists its vmid directly, or backs up "all guests" (scoped
to the job's node, if it has one) without excluding it.
Adds a warning banner plus a full table to the Proxmox page's Backups
card, and extends the existing daily "Proxmox backup failed"
notification (relabeled to mention this too) to also list uncovered
guests, gated by the same toggle.
Verified the coverage logic directly (it's a pure function, so no
fake server needed) across 7 cases: no jobs at all, an all-guests job
with an exclude list, a specific-vmids job, a node-scoped job that
shouldn't cover a guest on a different node, a disabled job providing
no real coverage, two jobs whose combined scope covers everything
neither would alone, and a realistic mixed scenario — all passed.
Confirmed the real dev database's mtime was untouched throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Instant notifications (DNS changes, integration failure/recovery
alerts) had no way to avoid pinging overnight. Adds a "Quiet hours"
window under Settings -> Notifications: notifications that would fire
during the window are held in a new notification_queue table instead
of sent immediately, then delivered as one combined digest at the end
time (in the same timezone already used for the daily checks) via a
new scheduled flush job. The scheduled daily checks (secret expiry,
Tailscale key, Docker updates, Proxmox backups) already only fire
once at a chosen time, so this mainly matters for the instant ones.
Includes a live "N queued" indicator with a manual "Flush now" button
for visibility, and correctly falls back to sending immediately
whenever the feature is disabled (the default).
Gating lives at the single choke point every notification already
flows through (notify()), so no per-event-type wiring was needed.
Verified against a fake webhook receiver on an isolated scratch
database: exhaustively checked the midnight-wraparound window math
(9 cases including exact-boundary inclusive/exclusive edges) against
synthetic "now" values rather than depending on when the test
happens to run, then end-to-end through the real notify()/
flushQuietHoursQueue() functions with a window constructed around the
actual current time — confirmed a notification during the window
queues instead of sending, the flush produces one digest with the
original title/message intact and clears the queue, a notification
outside the window sends immediately, and disabling the feature
entirely sends immediately regardless of the window. Confirmed the
real dev database's mtime was untouched throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Proxmox already runs vzdump backups, but nothing in the app said
whether they were actually succeeding — a silent backup failure is
one of the more dangerous blind spots a homelab admin can have. Adds
a "Backups" card to the Proxmox page: configured backup job
schedules (storage target, which guests, enabled/disabled) from
GET /cluster/backup, and recent vzdump task history per node from
GET /nodes/{node}/tasks?typefilter=vzdump, with a banner at the top
if the most recent run didn't succeed.
New "Proxmox backup failed" notification toggle under Settings ->
Notifications, on the same daily schedule as the other checks. The
scheduler checks each node's own most-recent vzdump run independently
(not just the single most recent task overall) so one node's healthy
backup can't mask another node's failing one in a multi-node cluster.
Known limitation, documented in the adapter's own header comment:
Proxmox's task list doesn't reliably expose which specific guest
failed within an "all guests" job — only the task's own log text has
that — so this surfaces job- and task-level status rather than
guessing at per-guest outcomes.
Verified against a fake Proxmox server (real self-signed HTTPS, since
the adapter's node:https usage can't be monkey-patched under ESM)
reproducing the documented /cluster/backup and task-list response
shapes: job parsing (all-guests+exclude vs specific-vmids+disabled)
correct, task OK/failure parsing correct, and the critical multi-node
scenario confirmed — one node's failing latest run flagged, the
other's healthy latest run correctly left alone, with exactly one
notification of the right content. This reproduces Proxmox's
documented API shape rather than a live-verified one; flag if the
real cluster's response differs in some way this didn't anticipate.
Confirmed the real dev database's mtime was untouched throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No visibility existed into who was currently signed in or a way to
force a device out. Sessions already live as files via
session-file-store, so this reads that store directly rather than
adding a new DB table: new Settings-adjacent "Sessions" page
(admin-only, alongside Users) lists every live session with the
user's name/email/resolved role, IP, a friendly "Browser on OS"
summary parsed from the user-agent, last-active time, and expiry, with
a Revoke button per row (extra confirmation if you revoke your own
current session, since that signs you out immediately).
IP and user-agent are now captured into the session at login
(auth/router.ts) since express-session doesn't track them itself.
session-file-store's own Store type doesn't declare its list()
method, so sessionStore.ts adds a narrow local interface for it rather
than losing type safety on the rest of the store.
Verified against a real session directory seeded through the actual
session-file-store APIs (not hand-written JSON): confirmed correct
field resolution including a session whose user row was later deleted
(role resolves to null instead of crashing), correctly excluded a
mid-OIDC-login session with no completed user yet, correctly excluded
an already-expired session, and confirmed revoke actually deletes the
right session file and only that one. Confirmed the real dev
database's mtime was untouched throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dockhand's pending-update counts were only visible if you happened to
open the Docker page. New "Docker image update available" toggle
under Settings -> Notifications, sharing the same daily
time/timezone as the secret and Tailscale key expiry reminders (same
node-schedule reschedule-on-settings-change pattern as those two).
Reads each enabled Dockhand integration's already-cached update-check
results via listContainers() rather than triggering a fresh
per-container registry lookup, so it costs nothing extra beyond what
the Docker page itself already fetches, and lists every container
with an update pending across all environments/integrations in one
notification.
Verified end-to-end against a fake local Dockhand server (one
environment, two containers, one flagged with a pending update) and a
fake webhook receiver on an isolated scratch database: the check
correctly found only the flagged container (with its newerVersion),
sent exactly one notification with the right title/content, excluded
the up-to-date container, and sent nothing at all when the setting
was toggled off despite still finding the same pending update.
Confirmed the real dev database's mtime was untouched throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The backdrop div was nested inside .modal instead of rendered as its
sibling. Tabler/Bootstrap's backdrop (z-index 1050) is only supposed
to sit below the modal (z-index 1055) because normally they're both
direct children of the same stacking context; nesting it inside
.modal instead made it establish z-index inside .modal's own stacking
context, where it painted on top of .modal-dialog and silently
absorbed every click meant for a result row (confirmed via
elementFromPoint in a real browser: clicking a result hit
.modal-backdrop, not the result div). Moved the backdrop to be a
sibling rendered before .modal, matching the structure elementFromPoint
now confirms resolves to the actual result element.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously a search result for a secret/IP/integration/DNS record
only filtered or scrolled to the right page — you still had to find
and click it yourself. Now:
- Secrets/IPAM results pass ?editId= and the page auto-opens that
row's existing edit form on load (viewers still just get the ?q=
filter, since they can't edit).
- Integration results now link to /integrations?editId= (which
auto-opens IntegrationEditForm for that row) instead of the type's
live dashboard page (/proxmox, /tailscale, etc.) — the dashboard
can't distinguish between two integrations of the same type anyway,
so it wasn't landing on "the" entry when more than one existed.
- DNS record results now also pass recordType/recordName/recordContent
so Dns.tsx, after auto-selecting the right zone, finds that exact
record by (type, name, content) and opens its edit form too. That
triple was used instead of an id because the records-list endpoint
returns both the cache table's internal integer id and the
provider's own string record id under different field names, and
matching by content sidesteps that ambiguity entirely rather than
risking picking the wrong one.
Servers and DNS zones/providers already landed on their real entry
(server detail page; zone/provider auto-selected) so those are
unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
With 6 integrations, DNS, secrets, IPAM, and servers all in one app,
there was no single place to type a hostname/IP/name and jump
straight to it. New GET /api/search aggregates a LIKE-based search
across servers, secrets, IPAM, integrations, DNS providers, DNS
zones, and DNS records (joining zone/provider names onto each record
result) in one round trip — homelab-scale row counts make a naive
LIKE scan plenty fast, no FTS needed. Every underlying resource's own
list endpoint already only requires requireAuth (viewer role
included), so the aggregate endpoint uses the same single check.
Frontend: a self-contained CommandPalette component (Tabler's modal
CSS classes driven by React state, since the app doesn't load
Bootstrap's JS) opens via a sidebar search button or Ctrl/Cmd+K from
anywhere, debounces input, and supports arrow-key navigation. Results
link to the right page: servers to their existing /servers/:id
detail route; DNS zone/record results deep-link via new ?providerId=
&zoneId= query-param handling added to Dns.tsx (auto-selects that
provider/zone on load, since Dns.tsx previously held selection only
in local state with no URL sync); secrets/IPAM results link with
?q= to prefill each page's existing client-side search box;
integrations link to their type's dashboard page (no per-instance
route exists yet, so same-type integrations share one link).
Verified the query logic (joins, case-insensitive LIKE, correct
zone/provider name resolution) against an isolated scratch database
seeded with realistic cross-referencing rows — a server name match, a
case-mismatched match against both a secret and a DNS provider
sharing "cloudflare", an IP address matching both an IPAM entry and
the DNS A record pointing at it (confirming the record's joined zone
and provider names came through correctly), an integration name
match, and a no-match query returning every category empty. Did not
re-verify the requireAuth/asyncHandler wiring itself, since it's the
same one-line pattern already proven across every other router in
this app. Confirmed the real dev database's mtime was untouched
throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nothing let you back up or migrate the app's own configuration short
of copying the raw SQLite file. Adds Settings -> Backup: export
decrypts every integration/DNS provider credential (normally
encrypted at rest with this server's CREDENTIALS_ENCRYPTION_KEY) and
re-encrypts the whole payload with a passphrase you choose (scrypt-
derived key, AES-256-GCM), so the file is portable to a different
instance with a different encryption key rather than being tied to
this one. Import decrypts with that passphrase and merges settings
onto the current ones; integrations/DNS providers are only added when
no existing row shares their type+name, so re-running an import never
duplicates or overwrites a working credential.
Scope is configuration only — no DNS records, secrets, IPAM, servers,
or audit/diagnostic log data.
Verified end-to-end against two isolated scratch databases with
different encryption keys (proving actual cross-instance portability,
not just round-tripping through the same key): export -> encrypt ->
write file -> decrypt on the other DB -> import -> re-decrypt the
newly created integration/provider using the target's own key,
confirming the plaintext credentials survived correctly; a wrong
passphrase failed loudly (GCM auth failure) as expected; and
re-running the same import a second time skipped both rows instead of
duplicating them. Confirmed the real dev database's mtime was
untouched throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Diagnostic Log already records every outbound call's success or
failure, but nothing acted on it — you'd only notice an integration
was down by happening to open its page. Adds a per-source consecutive-
failure counter (in-memory, reset on restart, same durability tier as
the diag log's own ring buffer) hooked into recordDiagEntry: crossing
the configurable threshold (default 3) sends one "down" notification
on every configured channel, and a "recovered" notification fires once
it succeeds again — no repeat spam while it stays down. New
"Integration/DNS provider failing repeatedly" toggle and threshold
field under Settings -> Notifications.
Verified end-to-end against an isolated scratch database with a real
local HTTP server standing in for the webhook channel: 5 consecutive
failures produced exactly one "Down" notification (at the 3rd
failure, correctly naming "3 calls"), a subsequent success produced
exactly one "Recovered" notification, and two more failures on a
fresh streak triggered nothing (below threshold) — confirmed the real
dev database's mtime was untouched throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The diagnostic log already rings-buffer to 500 rows, but the audit
log had no cap at all and would grow forever. Adds an opt-in
age-based purge under Settings -> Logs: keep entries for N days,
checked on a configurable interval (hourly through monthly), plus a
manual "Purge now" button. Reuses the existing node-schedule-style
reschedule-on-settings-change pattern from the secret/Tailscale
expiry checkers, but as a plain setInterval since "how often" here is
an interval rather than a specific daily time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Personal, per-browser preference (localStorage), not an admin-wide
Display setting like date/time format — every role can pick their own.
Sets data-bs-theme on <html> to hook into Tabler's built-in dark
variant, applied via an inline script in index.html before React
mounts so there's no light-mode flash on load. Toggle button lives in
the sidebar footer next to Sign out.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tabler's form-check-single modifier zeroes margin on .form-check-input,
which cancels the -2rem margin-inline-start the base .form-check rule
relies on to offset the checkbox into its label's padding gutter. That
left checkboxes floating almost flush against their label text.
Removed the form-check-single class from all 9 affected checkboxes:
6 in NotificationSettings, 1 each in DnsProviderForm, IntegrationForm,
and IntegrationEditForm.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DNS and Secrets each rendered as three separate stat-tile cards plus a
fourth full-width breakdown card -- visually a different family from
the single self-contained card each integration widget uses (label +
status badge, a compact stat row, then its own breakdown bar inline).
Extracted the integration widgets' card shell into a shared WidgetCard
component (label + badge header, children below) and rebuilt DNS and
Secrets on top of it instead of StatTile/TypeBreakdown, which are now
unused and removed. Also refactored the Integrations map itself onto
WidgetCard, so all eight dashboard widgets (DNS, Secrets, and the six
integrations) are now literally the same component, not just visually
similar.
DNS and Secrets get a badge like the integrations' Connected/Not
connected -- "Not configured" (gray) when nothing's set up yet, or a
status summary otherwise ("X enabled" for DNS; "All OK" / "X expiring"
/ "X expired" for Secrets, mirroring how a failing integration shows a
warning-colored count instead of a plain badge). They now sit under
one "Overview" heading in a shared row instead of two separate
sections, matching how the integration widgets already share one
row-cards grid.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pagination just landed hardcoded to 20 rows everywhere; add a way to
change that instead of leaving it fixed for every table in the app.
pageSize joins dateFormat/timeFormat on the existing display settings
object (server-side default 20, 5-500 range enforced by the PUT
schema) rather than becoming its own settings section, since it's the
same kind of thing -- an admin-configured, globally-applied display
preference read by every signed-in role via the already-public GET
/api/settings/display endpoint, same as the date/time format already
works.
Renamed the "Date & Time" settings tab/page/route to "Display" (still
just one component, now covering both date/time format and table
pagination) since its scope no longer matches the old name -- kept
Settings.tsx's usual pattern of one page per concern rather than
adding a second, oddly-scoped tab just for one number field.
New web/src/utils/pageSize.ts mirrors utils/date.ts's existing
module-level "set once at startup, read anywhere without prop-
drilling" pattern; usePagination()'s pageSize parameter now defaults
to getPageSize() instead of a literal 20, evaluated fresh on every
call so it picks up a saved change without touching any of the ten
pages already using the hook.
Verified server-side against a temp SQLite DB: pageSize defaults to
20, a partial update sets it without disturbing dateFormat/timeFormat
and vice versa, and it persists across a fresh settings read. Also
checked the default-parameter mechanics directly (re-evaluates the
global value on every call rather than capturing it once, and an
explicit override still wins).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sorting/CSV export were already app-wide; tables with a real chance of
growing into dozens or hundreds of rows (a busy tailnet, a big DNS
zone, a homelab's full IP inventory, a Gitea org with many repos, ...)
had no pagination at all, making them a long unbroken scroll.
New usePagination hook (client-side slicing over an already-sorted/
filtered array, 20 rows per page) and a matching Pagination component
(Prev/Next + "Page X of Y (N total)", hidden entirely when everything
fits on one page). The current page is clamped to the valid range on
every render rather than reset via an effect, so switching to a
smaller data set (a different selected integration, a filter that
narrows the result) can never strand the view on a now-nonexistent
page -- no per-page "reset on change" wiring needed anywhere.
Applied to Audit Log, DNS zones and records, IP Addresses, Secrets,
Servers (manage table), Docker containers, Proxmox guests, Semaphore
templates, Gitea repos, and Tailscale devices. CSV export keeps
exporting the full sorted/filtered array regardless of which page is
currently shown -- pagination only affects what's rendered on screen.
Left the already-small tables (Synology volumes/disks, Users,
Integrations, per-node Proxmox storage) unpaginated, and left the
Diagnostic Log's existing server-driven pagination as-is rather than
bolting a second, different pagination scheme onto it.
Verified the clamping logic directly: a normal page, the trailing
partial page, a requested page beyond the end (clamps to the last
valid page instead of rendering empty), and an empty result set
(clamps to page 0 with a page count of 1 instead of a negative range).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The agent path showed a per-mount usage table; a Proxmox-linked server
only ever got a single "Disk (allocated)" figure, since that's all the
VM/LXC config alone can tell you -- it's the attached disk's declared
size, not how full it actually is inside the guest. Fix the actual gap
instead of just matching the display: fetch real usage where Proxmox
can see it.
adapter.getGuestDetail() gained a `disks` field (same {mount,
sizeBytes, usedBytes} shape the agent already reports, so the frontend
renders both identically):
- LXC: the host can read straight into the container's root
filesystem, no agent needed -- status/current's disk/maxdisk fields
are real usage, not just allocation.
- QEMU: the hypervisor can't see inside a virtual disk at all without
help, so this calls the QEMU guest agent's get-fsinfo command (same
"gracefully degrade if the agent's missing/older" tolerance already
used for its IP-address lookup, and independent of it -- one
command failing doesn't take out the other). Pseudo-filesystems
(tmpfs, etc.) are filtered out by checking for a non-empty backing
`disk` array, the common convention for this endpoint.
Extracted the disks-table JSX (previously only in the agent branch)
into a shared DisksTable component and used it in both branches, and
added a note explaining an empty result when a running QEMU VM's
guest agent doesn't support get-fsinfo (an older agent version).
Verified against a mock Proxmox API over real TLS: an LXC's root
usage, a QEMU VM's real fsinfo mounts (with the disk-less tmpfs entry
correctly filtered), and a QEMU VM whose get-fsinfo fails outright --
confirming that degrades to an empty disks list without throwing and
without affecting the separate network-get-interfaces result.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Not every registered server is a Proxmox VM/LXC -- bare-metal boxes
and other hosts had no reason to show a "link to Proxmox" option, but
it appeared unconditionally on every server's detail page.
New hideProxmoxLink column on servers (default false, so existing
behavior is unchanged until someone opts in). A "Not a VM? Hide this"
link in the card's header sets it; once hidden, a small "+ Show
Proxmox link options" link takes its place so it's still reachable,
not buried in a settings form. The card always shows regardless of
this flag once a server IS actually linked, so unlinking never becomes
unreachable by hiding the card out from under an active link.
Verified against a temp SQLite DB with real migrations: a new server
defaults to false, and toggling true/false both persist correctly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dockhand already tracks per-container image updates internally (its
UI shows this) via a cached check plus an on-demand recheck -- surface
that here instead of only showing running/stopped state.
adapter.listContainers() now also reads GET /api/containers/pending-
updates per environment (a cached read, no registry hit) and merges
each container's hasImageUpdate/newerVersion/checkedAt onto it.
updateAvailable is a tri-state: true (update pending), false (checked,
up to date), or null (never checked) -- distinguishing "no update"
from "we don't know yet" matters since a container can sit unchecked
indefinitely until someone triggers a check.
New adapter.checkForUpdates() triggers a fresh check across every
environment (POST /api/containers/check-updates, one registry lookup
per container so this can take a while) and new POST /:id/dockhand/
check-updates route (operator+, matching the existing container
action's role gating).
Docker page: new "Update" column (badge + tooltip with the newer
version), an "updates available" count next to the running/total
count, and a "Check for updates" button. Dashboard's Dockhand widget
also gained an "Updates" mini-stat, swapped in for the less useful
"Not running" figure (already inferable from running/total).
Field names (hasImageUpdate, newerVersion, checkedAt, the check-
updates response shape) came from Dockhand's own published OpenAPI
spec, not guessed -- and verified against a local mock Dockhand server
covering all three update states (pending, up to date, never checked)
plus the check-updates aggregation across environments, since this
sandbox can't reach the user's real Dockhand instance.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The add/edit forms already had "Test connection", backed by existing
POST /api/integrations/:id/test (admin-only, pings with the stored
config) and POST /api/integrations/test (for a not-yet-saved one) --
but there was no way to re-test an already-configured integration
without opening its edit form. Add a "Test" button next to Edit/
Delete on each row, showing an OK/latency or Failed/error badge
inline once it returns. Pure frontend wiring to the existing endpoint,
no server changes needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Flagged back when Proxmox/Synology got their own pages: once all six
integration types had moved to dedicated pages, the "browsing"
dropdown here only ever showed one of six near-identical "go manage
this on its own page" redirects, and its final fallback branch was
dead code (every IntegrationType was already covered). Now that all
the actual data views have moved out, drop that mode entirely.
The page is now just what "Manage integrations" already was: a
sortable, CSV-exportable table of configured integrations (name/type/
status), visible to every role since none of that is sensitive, with
an admin-only "Add integration" button and per-row edit/enable-disable/
delete actions. No more mode toggle, no provider-picker dropdown, no
per-type redirect cards -- the six dedicated pages (and the sidebar
nav that already points at them) are how you actually use each
integration now.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The six integration cards only ever showed one headline number each
(e.g. "3/5 devices online") -- thin compared to the new DNS/Secrets
sections' stat-row-plus-breakdown layout. Each widget now shows 2-3
mini stats plus a compact breakdown bar, using data these endpoints
already return (so no new API calls except one extra Synology call for
CPU/RAM, matching what the Synology page itself already fetches):
- Tailscale: online/unauthorized/expiring-soon stats + devices by OS
- Proxmox: running/total + VM vs LXC counts + guests by node
- Dockhand: running/total + host count + containers by state
- Semaphore: template/failing counts + templates by last-run status
- Gitea: repo/private/failing counts + repos by last-run status
- Synology: volumes/unhealthy/CPU load + RAM used-of-total + disks by
health status
Extracted the breakdown-bar rendering out of the DNS/Secrets-only
TypeBreakdown into a bare BreakdownBar (no card wrapper, optional
compact sizing) so it drops into each integration card directly, and
factored the per-item counting into a small breakdownFrom() helper
used by all six. Status/state colors reuse the same palette each
integration's own dedicated page already uses for its badges (Docker's
container-state colors, Semaphore/Gitea's run-status colors); open-
ended categories (OS names, Proxmox node names) get a generated
palette instead since there's no fixed enum to hardcode against.
Widget cards moved from a 3-column to a 2-column grid to fit the
extra content without feeling cramped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sloth Manager's dashboard showed per-system stats for DNS and Secrets
(domains/records by type, expiry counts by type) that this app's
Dashboard never had -- it only ever showed the six live integration
widgets. Port those two sections over, generalized to sit alongside
the integrations this app added that Sloth Manager never had.
New server/src/dns/stats.ts (getDnsStats(), used by new GET
/api/dns/stats): zone counts are fetched live per enabled provider
(matching what the DNS page itself shows, since the zone cache table
only gets a row once a zone has been synced and would undercount) --
one unreachable provider surfaces its error without blanking the rest.
Record-type breakdown comes from the local cache instead, since
records are only ever shown from cache elsewhere in this app too
(fetching every zone's records live on every dashboard load would be
far more expensive for no real accuracy gain).
Dashboard.tsx gained three sections under clear headers: DNS (domain/
provider/record stat tiles + a "records by type" breakdown), Secrets
(monitored/expiring/expired tiles + a "secrets by type" breakdown,
computed client-side from the already-fetched secrets list, same as
Sloth Manager did), and the existing Integrations widgets grouped
under their own header for visual parity with the two new sections.
Breakdowns render as a stacked proportion bar with a color-coded
legend rather than a pie chart -- this app has no charting library
anywhere yet, and a plain CSS progress bar (an idiom Tabler itself
uses) gets the same "see the mix at a glance" value without adding one
for a single dashboard.
Verified getDnsStats() against a temp SQLite DB with real migrations
and an enabled + a disabled DNS provider: enabled/total provider
counts, live zone counting, and cached-record-type aggregation (sorted
by count) all came back correct, and the disabled provider was
correctly excluded from the zone count while still counting toward
the total.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each server's detail page gets an "Admin Links" card for bookmarking
that host's own web UIs -- container managers, Webmin, Cockpit, or
anything else reachable by URL -- so there's a quick way to jump
there without hunting down the address each time.
New server_links table (serverId FK, label, url, ON DELETE CASCADE so
removing a server cleans up its links automatically) and three new
routes: POST/PATCH/DELETE /api/servers/:id/links, gated to operator+
like the rest of this page's editing actions; the existing GET
/:id/detail now includes the server's links alongside hardware/DNS
info. URLs are validated to start with http:// or https:// server-side
(rejecting e.g. a javascript: URL that would otherwise render as a
clickable link).
Rendered as a row of pill buttons (label opens the URL in a new tab),
with inline edit/remove controls next to each when the viewer can
edit, and an "Add link" form matching the existing task-form style on
the same page.
Verified against a temp SQLite DB with real migrations: create/list/
update/delete all work, and deleting the parent server cascades to
remove its links rather than leaving them orphaned.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Applies the useSortable/SortableTh infrastructure introduced in the
Diagnostic Log commit to the rest of the app's tables: Audit Log,
Users, Servers (manage table), server task tables (grouped by
schedule type, on both the all-servers and per-server views), DNS
providers/zones/records, Docker containers, Gitea repos, Integrations
(manage table), IP Addresses, Proxmox guests, Secrets (already had
CSV, gained sorting), Semaphore templates, Synology volumes/disks, and
Tailscale devices.
Every column header is now click-to-sort (again on the raw field, not
its formatted display -- a byte count sorts numerically even though
the cell shows "1.2 GB", a date sorts chronologically even though the
cell shows "5d 15h 2m"), and every table got an "Export CSV" button
next to its Refresh button, exporting whatever's currently
sorted/filtered via the existing downloadCsv util.
Grouped tables (ServerTaskTable renders one sub-table per schedule
type) can't call the useSortable hook per group without breaking the
Rules of Hooks, so extracted its comparison core as a standalone
sortItems() function, driven by one shared sort-state pair at the
component's top level and applied per group.
Deliberately left two small (1-6 row) tables embedded inside stat
cards unsorted -- Proxmox's per-node storage list and ServerDetail's
agent-reported disk list -- since they're secondary detail inside an
already-scannable card, not primary list content; happy to add if
useful in practice.
Verified the shared sort core (sortItems) directly: numeric-aware
string compare (so "item2" sorts before "item10"), numbers, booleans,
and that null/undefined always sort to the end regardless of
direction.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sloth Manager tracked every API call made to DNS providers for
connectivity troubleshooting. Port that here, generalized to cover
every outbound integration this app makes, not just DNS -- Tailscale,
Proxmox, Synology, Semaphore, Gitea, and Dockhand calls now show up
too, since a broken API token or unreachable host on any of them is
just as worth diagnosing.
New services/diagLog.ts: a generic withDiagLogging(source, adapter)
wraps every async method of any adapter object with timing +
success/failure recording, without touching a single adapter's
request/error-handling internals -- every DNS and integration adapter
interface here is already just a flat set of async methods, so this
one wrapper works for all twelve of them. Applied it at each adapter
factory's own return statement (one line each) rather than at the
route layer, so background jobs that construct adapters directly
(the Tailscale key-expiry scheduler, IPAM sync, agent-driven Proxmox
lookups) get logged too, not just requests through routes/integrations.ts.
New diag_log table (ring-buffered to the last 500 rows, mirroring
Sloth Manager's approach -- this is for live troubleshooting, not a
durable record) and admin-only GET/DELETE /api/diag-log routes, source/
result filters, pagination.
New admin-only Diagnostic Log page: filterable, paginated table with a
Clear button. Also introduces the shared useSortable hook + SortableTh
component used here for the first time -- a follow-up commit applies
the same sorting (and CSV export) to the rest of the app's tables, per
the same request.
Verified end-to-end against a temp SQLite DB with real migrations: a
fake wrapped adapter's successful and failing calls both land correctly
in the log with the right source/operation/latency/error, and the
source/ok filters and clear-log operation all behave correctly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Node cards were col-lg-6 (half width, side by side), while the
VM/LXC table below is full width -- looked mismatched, especially
with a single node. Full width also gives the left/right system-
info/storage split inside each card more room to breathe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Was a single stacked column (uptime/CPU/memory/swap, then the storage
table below); now a two-column layout within the card body — system
stats on the left, storage table (or the missing-permission hint) on
the right — so the card reads more like a dashboard tile at a glance.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed via the user's rebuild: my previous fix caught a *thrown*
storage-fetch error, but Proxmox's /nodes/{node}/storage instead
returns 200 with an empty array when the API token has no
Datastore.Audit on any storage — it filters the list per-token rather
than erroring the whole call. That shape has error === null and
storages.length === 0, so it fell through both my error banner and
the "no storages" render guard, showing nothing.
The Storage section now renders a hint (naming the missing
Datastore.Audit privilege) whenever storages is empty and no error
was recorded, instead of just disappearing. CPU/RAM/uptime are
unaffected by this since they come from the separate /status call.
Verified against a mock Proxmox API returning this exact 200-with-
empty-array shape for /storage.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
listNodeStats() was silently dropping any node whose /status or
/storage call failed and filtering it out of the result — with every
node dropped, the page showed the same "no nodes" empty state as a
genuinely nodeless tailnet, even though the node was online and the
existing VM/LXC table below it worked fine.
The likely real cause: Sys.Audit (host status) and Datastore.Audit
(storage) are different ACL privileges from the VM/LXC management
scope this integration originally needed, so a token created before
this feature existed may lack them.
getNodeStats() now fetches /status and /storage independently via
Promise.allSettled instead of Promise.all, so one endpoint failing
doesn't discard data the other successfully returned, and records a
per-endpoint error message. listNodeStats() no longer filters failed
nodes out at all -- it always returns one entry per online node, with
`error` set and the rest of the fields null when nothing could be
fetched. The Proxmox page now shows that error inline on the node's
card instead of it vanishing.
Verified against a mock Proxmox API returning 403 on /storage only
(partial data still shows) and on both endpoints (node stays visible
with its error surfaced, not silently dropped) — reproducing the
reported bug and confirming the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Proxmox page only showed guest (VM/LXC) status; there was no view
of the underlying host(s) themselves -- uptime, CPU/RAM usage, or how
full each storage pool is.
New adapter.listNodeStats() calls GET /nodes/{node}/status and GET
/nodes/{node}/storage for every online node (a node going down
shouldn't blank the whole page, same tolerance as listGuests()) and
returns uptime, CPU usage % + core count + load average, RAM/swap
usage, and per-storage (local, LVM-thin, ZFS, NFS, ...) used/total.
New GET /:id/proxmox/nodes route alongside the existing /guests one.
One card per online node now sits above the VM/LXC table, showing
these stats plus a small storage table with usage bars (reusing the
same green/yellow/red thresholds as the Servers detail page and the
Synology page). Handles a multi-node cluster by rendering one card
per node, and storages with no active/total data (e.g. an offline NFS
mount) render as an "Inactive" badge with blank usage instead of
throwing.
Verified end-to-end against a local mock Proxmox API server over real
TLS (a throwaway self-signed cert, matching how the adapter's
`insecure` option is meant to be used) since this sandbox can't reach
the user's actual Proxmox cluster -- confirmed CPU fraction-to-percent
conversion, load average parsing, byte fields, and that a
null-valued/inactive storage doesn't break parsing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Tailscale page already showed per-device key expiry; extend the
existing daily-reminder infrastructure (currently only for Secrets)
to push it out through the configured notification channels too, the
same way expiring secrets already are.
New tailscaleKeyExpiryScheduler.ts mirrors secretExpiryScheduler.ts:
runs once at startup (skipped if already run today) and daily
thereafter, checking every enabled Tailscale integration's devices for
keys expiring within the warning window and calling notify() with the
results. Reuses the exact same daily time/timezone setting as the
secret-expiry check (one "Daily reminder time" control, two
independent on/off toggles) rather than adding a second schedule for
users to configure.
Centralized the expiring-soon threshold and check (previously only
duplicated in the /synology and /tailscale route summaries) into
adapter.ts as `KEY_EXPIRY_WARN_DAYS` / `isKeyExpiringSoon()`, and
updated the devices route to use it instead of its own inline copy.
New `tailscaleKeyCheck` notification-event toggle (default on) in
settings, alongside the existing secret-expiry one.
Verified end-to-end against a temp SQLite DB + real migrations: a
tailscale integration pointed at a mock Tailscale API (one device
expiring in 10 days, one with key-expiry disabled) with the webhook
channel enabled and pointed at a mock receiver — confirmed the
scheduler's startup check queries the DB correctly, decrypts the
integration's credential, calls the adapter, filters out the
disabled-expiry device, and delivers a webhook payload naming only
the expiring device.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tailscale node keys expire (180 days by default unless disabled per
device) and an expired key drops the device off the tailnet until
re-authenticated -- worth surfacing before it happens.
adapter.listDevices() now reads `expires` and `keyExpiryDisabled` from
the device list API (`?fields=all`, already being fetched). Go's zero
time ("0001-01-01T00:00:00Z") is what Tailscale returns for "no real
expiry set" and is treated as null rather than shown as a bogus 1AD
date.
New "Key expiry" column on the Tailscale page: "Never" when disabled,
otherwise the date plus a badge (green/yellow/red matching the
Secrets module's ok/expiring/expired convention) using the same
30-day warning window as that module's default. The devices summary
gained `expiringSoon` (<=30 days left, including already-expired),
surfaced in the page header and as a new warning line on the
Dashboard's Tailscale widget, alongside the existing "awaiting
authorization" one.
Verified the parsing (real expiry, disabled/zero-time, already-expired,
far-future) against the compiled adapter with fetch calls to
api.tailscale.com redirected to a local mock server, since this
sandbox can't reach the user's real tailnet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Scheduled-task browsing/editing already lives on each server's detail
page (ServerDetail.tsx), so the overview page's filters, task-group
listing, and add/edit-task form were pure duplication -- the only
things it needs to do are list servers (as clickable cards) and let
an admin register new ones / issue or rotate agent tokens.
Renamed ServersTasks.tsx -> Servers.tsx to match its new, narrower
scope; updated the nav label, route, and ServerDetail's back-link
text from "Servers & Tasks" to "Servers" accordingly. No server-side
or API changes -- this only removes now-redundant frontend code.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Beyond volume/disk health, DSM exposes hostname, CPU, RAM, IP
addresses, model/serial/firmware, and uptime -- surface those too as a
"System" card on the Synology page.
New adapter.getSystemInfo() combines three DSM Web API calls (fields
confirmed against the actively-maintained mib1185/py-synologydsm-api
client, which documents the same SYNO.Core.System / SYNO.Core.System.
Utilization / SYNO.DSM.Network endpoints this adapter already uses the
discover-then-call pattern for):
- SYNO.Core.System "info" -- model, serial, firmware_ver, cpu_cores,
cpu_clock_speed, up_time (a raw uptime-command-style string, not a
duration -- parsed client-side into "5d 15h 2m" with a fallback to
the raw string if DSM ever returns an unexpected format).
- SYNO.Core.System.Utilization "get" -- live CPU load % (user+system+
other) and real memory usage, in KB.
- SYNO.DSM.Network "list" -- hostname and interface IP addresses
(loopback filtered out).
New GET /:id/synology/system route alongside the existing /storage
one, read-only like the rest of this integration.
Verified end-to-end against a local mock DSM server standing in for
the real API (login/session flow, all three endpoint shapes, IP
filtering, byte math) since this sandbox can't reach the user's LAN;
also unit-checked the client-side uptime regex against DSM's three
uptime-command formats (with/without days, minutes-only) plus its
unmatched-format fallback.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same move as Docker/Tailscale/Semaphore/Gitea: each was buried inside
the generic Integrations browsing view. Give them dedicated pages too
— this was the last pair, so every integration type with a browsing UI
now has its own page.
New web/src/pages/{Proxmox,Synology}.tsx reuse the existing, unchanged
API routes and adapters (no server changes) with their own integration
picker scoped to just that type. Added matching nav items and routes.
Synology stays read-only, matching its original design.
Removed the corresponding state/handlers/tables from Integrations.tsx
(296 lines). It's now down to just the "Manage integrations" CRUD
table plus a browsing dropdown whose six branches are all redirects to
the type's own page — genuinely pointless now that every type has
moved out, worth simplifying separately.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same move as Docker: each was buried inside the generic Integrations
browsing view, sharing one "pick any integration" dropdown across six
unrelated types. Give them dedicated pages instead.
New web/src/pages/{Tailscale,Semaphore,Gitea}.tsx each reuse the
existing, unchanged API routes and adapters (no server changes) with
their own integration picker scoped to just that type. Added matching
nav items (Tailscale, Semaphore, Gitea, right after Docker) and routes.
Removed the corresponding state/handlers/tables from Integrations.tsx
entirely (374 lines). Adding, editing, enabling/disabling, and
deleting each credential itself still happens under Integrations ->
Manage integrations, same as Proxmox and Synology, which stay on the
Integrations page. Selecting one of these three types in Integrations'
generic browsing dropdown now points to its new page instead of
rendering a table there too.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dockhand aggregates containers across every Docker host it manages
into one list, with no way to narrow it down to a single host. Add a
host dropdown next to Refresh (only shown when more than one host is
present) that filters the table client-side over the already-fetched
container list. Resets whenever the selected Dockhand integration
changes, so a stale host name from a previous integration can't linger.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Container management was buried inside the generic Integrations
browsing view, mixed in with five unrelated integration types behind
a single "pick any integration" dropdown. Give it a dedicated page,
matching how Servers & Tasks and DNS already get their own top-level
spot instead of living inside Integrations.
New web/src/pages/Docker.tsx reuses the existing, unchanged Dockhand
API routes and adapter (no server changes) — it just has its own
integration picker scoped to Dockhand only, rather than sharing
Integrations' any-type dropdown. Added a Docker nav item (right after
Integrations) and route.
Removed the Dockhand-specific state/handlers/table from Integrations
entirely; adding, editing, enabling/disabling, and deleting the
Dockhand credential itself still happens under Integrations -> Manage
integrations like every other integration. Selecting a Dockhand row in
Integrations' generic browsing dropdown now points to the Docker page
instead of rendering a container table there too.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends the Tailscale IPAM sync to Proxmox: pulls every VM/LXC's IP
address(es) across all enabled Proxmox integrations into the
inventory, reusing the same never-overwrite-a-manual-entry semantics.
Proxmox guests can have multiple NICs (each IP synced as its own
entry) and, for QEMU VMs, IP discovery depends on a responsive guest
agent — a VM with none simply contributes zero entries rather than
erroring, matching getGuestDetail()'s existing best-effort behavior.
listGuests() doesn't include IPs, so this fetches getGuestDetail() per
guest; acceptable for an explicit, user-triggered sync rather than a
background poll.
Extracted the shared "insert, update only if I own this row, else
skip and report" upsert logic (previously inline in the Tailscale sync
handler) into upsertSyncedEntry(), now used by both sync routes so the
core safety rule can't drift between them.
Verified end-to-end against a mock Proxmox server covering: an LXC
with two NICs (both synced), a QEMU VM with a responsive guest agent,
a QEMU VM with no agent (zero entries, no error), a manual-entry
collision (skipped and reported, never overwritten), and idempotent
re-sync (add -> update on the second run).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
IPAM was entirely manual — Tailscale device IPs never showed up there
even though the Tailscale integration already lists them. Add an
explicit sync action (matching this app's existing pattern of
user-triggered syncs rather than silent background polling).
- New source column on ipam_entries (null = manual, "tailscale" =
auto-synced) so a re-sync only ever touches rows it created itself —
a manually-entered IP that happens to collide with a tailnet address
is left untouched and reported back as skipped, never overwritten.
- POST /api/ipam/sync-tailscale pulls every enabled Tailscale
integration's device list, upserting by primary IP (label, OS in
notes, vendor "Tailscale"); one unreachable Tailscale integration
doesn't block others.
- New "Sync from Tailscale" button on the IP Addresses page, with a
small "synced" badge marking which rows came from it.
Also fixed a longstanding TODO found in the same file: the "DNS
records" column always showed "-" because matchingDnsRecords was
hardcoded to an empty array from before the DNS module existed. It
now does the same content-based reverse lookup against the DNS
module's record cache used elsewhere in the app.
Verified end-to-end by running the real server with Tailscale's fetch
call intercepted at the process level (its adapter hardcodes
api.tailscale.com with no configurable URL, so it can't be pointed at
a mock server the way Proxmox/Synology can): confirmed add, the
manual-entry skip/never-overwrite behavior, idempotent re-sync
(add -> update), and the DNS-matching fix, all against the real
route and adapter code.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Date & Time setting drove formatDateTime() everywhere, but the
human-readable cron description ("At 08:30 AM every day") came from
cronstrue, a separate library with its own independent AM/PM default
that the setting never touched. Pass its use24HourTimeFormat option
from the same shared setting so "Schedule" columns match the rest of
the app.
Added utils/date.ts's is24HourFormat() getter for this, since
formatDateTime() itself doesn't apply here (cronstrue does its own
cron-to-English rendering, not just time formatting).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Different tables used different date formats: Servers & Tasks used a
fixed "YYYY-MM-DD HH:mm:ss" (24h), while Audit Log, DNS, Integrations
(Tailscale/Semaphore), and Users called plain .toLocaleString() with
no options, which renders using the browser's own locale — different
per browser/OS, and inconsistent with the other pages' fixed format.
- New Settings -> Date & Time page: pick date order (YYYY-MM-DD,
DD/MM/YYYY, MM/DD/YYYY) and 12h vs 24h clock, with a live preview.
- utils/date.ts's formatDateTime() now reads these settings instead of
being hardcoded to sv-SE/24h; the setting is fetched once at app
startup (alongside /api/me) via a new non-secret GET
/api/settings/display (any signed-in user, same rationale as the
badge-color endpoints) and applied immediately on save too, without
needing a page reload.
- Switched every remaining raw new Date(...).toLocaleString() call
(Audit Log, DNS zone sync time, Tailscale/Semaphore last-seen,
Users' last login) over to the shared formatter, so every table now
renders dates identically.
Verified the formatter's date-order x 12h logic against all six
combinations plus the midnight/noon 12h edge cases, and the settings
endpoints end-to-end (defaults, partial updates, validation
rejection, audit logging) against the real dev server.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The new integration badge-color setting only reached the Integrations
page's Manage table — the Servers & Tasks overview card grid had its
own hardcoded bg-purple-lt badge for Proxmox-linked servers that never
picked it up. Reuses the same typeBadgeStyle pattern already used
there and in the DNS module.
Note: until a Proxmox color is set in Settings -> Badges, this badge
now renders with Tabler's default neutral badge style rather than
purple, matching how every other badge customized this way already
behaves before a color is chosen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Settings → DNS Badges only let you customize DNS provider badge
colors. Expand it into a general Badges page with a second section
for the six integration types (Tailscale, Proxmox, Synology,
Semaphore, Gitea, Dockhand), applied to the type badge in the Manage
integrations table.
- New integrationColors key on AppSettings, stored/merged the same way
as providerColors via the existing settingsStore.
- New GET /api/settings/integration-colors — non-secret, any signed-in
user, mirroring /provider-colors — so the badge color can be read
without needing admin access to the full settings payload.
- Renamed DnsBadgeSettings.tsx -> BadgeSettings.tsx (route
/settings/dns-badges -> /settings/badges, sub-nav label "DNS
Badges" -> "Badges") with both color sections saved together.
Verified end-to-end against the real dev server: PUT persists
integration colors independently of provider colors, the public
integration-colors endpoint reflects updates immediately, and the
change is audit-logged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>