Compare commits

..

15 commits

Author SHA1 Message Date
Mitchell R
69450de009 fix: fallback to user-provided title when AbleSign API omits it
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:42:32 +02:00
Mitchell R
88bf4645f6 fix: probe for BSB entrypoint path (not hardcoded)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:36:59 +02:00
Mitchell R
5fa34d0568 fix: entrypoint wrapper chowns /var/lib/betterframe on startup
Named volume mounts as root-owned. BSB runs as uid 1000.
bf-entrypoint.sh fixes ownership before exec'ing BSB entrypoint.
Removed VOLUME directive (compose handles mount).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:33:29 +02:00
Mitchell R
6b2f56f092 fix: add betterframe-data volume to server in Coolify compose
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:20:21 +02:00
Mitchell R
58700be430 remove: root docker-compose.yml (Coolify manages its own compose)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 03:11:21 +02:00
Mitchell R
9db8d1d65b fix: clean up secret key generation log message
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:56:27 +02:00
Mitchell R
b739533ce1 fix: mount betterframe-data volume on server container
secret.key, firmware signing keys, and all encrypted data require
persistent storage at /var/lib/betterframe. Without this volume,
every redeploy regenerates keys and breaks all encrypted fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:53:36 +02:00
Mitchell R
d6a52df27a feat: global Settings page for AbleSign + Cloud Cam account config
- New /admin/settings page with AbleSign account setup (API key) and
  link to Cloud Cams config
- Settings nav item in sidebar (gear icon, before Account)
- Removed AbleSign Config from AbleSign dropdown (now in Settings)
- AbleSign account delete redirects to Settings
- Cloud Cams nav item kept for its own CRUD page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:47:54 +02:00
Mitchell R
f0088836e9 fix: add AbleSign Config link back to nav dropdown
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:45:17 +02:00
Mitchell R
01fcb66402 fix: surface AbleSign screen creation errors instead of swallowing
Previously caught and silently ignored. Now shows error message on
the screens page so we can debug the pairing flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:41:13 +02:00
Mitchell R
10f5cf7fac fix: per-connection search_path + sidebar tenant dropdown
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions
PG adapter: setSearchPath now stores schema name, runner applies
SET search_path on every connection checkout. Eliminates cross-request
schema bleed (previous: setSearchPath mutated shared connection state).

Middleware: always set search_path (removed 'public' skip condition).

Sidebar: tenant switcher dropdown at bottom, loaded via htmx from
/admin/_tenant_switcher. Hidden when only one tenant. Auto-submits
on change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:15:00 +02:00
Mitchell R
1dbb56752c fix: tenant switch auto-copies global admin into tenant users
isSetupComplete() now checks public.global_admins — if a global admin
exists but tenant has no local users, copies admin into tenant's users
table and marks setup complete. Prevents setup wizard on tenant switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:10:56 +02:00
Mitchell R
65de42d495 refactor: AbleSign UI — single account, screen detail, no kiosk assign
- Remove Accounts from AbleSign nav (one account per tenant)
- Screens page: create button, no kiosk assignment
- Screen detail page with config form
- Internal/External badge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 02:09:40 +02:00
Mitchell R
e0941f533d
feat: AbleSign dropdown nav + screens/content/playlists pages
- Sidebar: NavGroup component (details/summary) for AbleSign dropdown
  with Accounts, Screens, Content, Playlists sub-items
- Global screens page (/admin/ablesign/screens) — all screens across
  accounts with Internal/External badge
- Content page — aggregates media files + web apps from all accounts
- Playlists page — shows per-screen playlist items
- Auto-sync screens on account creation
- Internal/External: Internal = created via "Create & Pair" (has
  screenToken, gets entity). External = synced from AbleSign (no token,
  no entity, management-only). Only internal screens become entities.
- Entity creation only on Create & Pair path — not on sync or assign

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 01:47:17 +02:00
Mitchell R
e1a3cd1d05
fix: ONVIF single-kiosk ownership + rate limiting
Server-side:
- Bundle gen: when camera event_source is "auto", first kiosk to fetch
  bundle claims ownership → writes "kiosk:<id>" to camera row. Other
  kiosks see assigned owner and skip ONVIF subscription.
- Kiosk deletion resets event_source back to "auto" so next kiosk
  takes over.
- repo.getActiveOnvifOwners() for future use.

Kiosk-side:
- Only subscribe when event_source is "auto" or "kiosk:<MY_ID>".
  Skips "kiosk:<other_id>", "server", "none", "disabled".
- Poll interval: 3s → 10s (cameras were getting overwhelmed)
- CreatePullPoint backoff: exponential 30s→60s→120s...→600s max
- Pull errors: exponential 15s→30s→45s...→300s, resubscribe after 5
  consecutive failures instead of immediately.
- load_kiosk_id() helper reads from cached bundle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 01:40:44 +02:00
14 changed files with 516 additions and 224 deletions

View file

@ -46,7 +46,17 @@ ARG BF_MQTT_TOPIC_PREFIX=betterframe
RUN apk add --no-cache gettext ffmpeg
RUN mkdir -p /var/lib/betterframe && chown 1000:1000 /var/lib/betterframe
RUN mkdir -p /var/lib/betterframe \
&& { \
echo '#!/bin/sh'; \
echo 'chown -R 1000:1000 /var/lib/betterframe 2>/dev/null || true'; \
echo '# Find and exec the base image entrypoint or node directly'; \
echo 'if [ -f /root/entrypoint.sh ]; then exec /root/entrypoint.sh "$@"; fi'; \
echo 'if [ -f /usr/local/bin/entrypoint.sh ]; then exec /usr/local/bin/entrypoint.sh "$@"; fi'; \
echo 'if [ -f /home/bsb/entrypoint.sh ]; then exec /home/bsb/entrypoint.sh "$@"; fi'; \
echo 'exec node /home/bsb/lib/index.js "$@"'; \
} > /usr/local/bin/bf-entrypoint.sh \
&& chmod +x /usr/local/bin/bf-entrypoint.sh
# Install plugin into BSB's node_modules (searched at /home/bsb/node_modules)
# /mnt/bsb-plugins is a VOLUME in base image — writes lost at runtime
@ -67,6 +77,6 @@ RUN echo "$BF_SERVER_VERSION" > /home/bsb/.bf-version
ENV NODE_ENV=production
ENV BSB_LIVE=true
VOLUME /var/lib/betterframe
ENTRYPOINT ["/usr/local/bin/bf-entrypoint.sh"]
EXPOSE 18080 18081 18082

View file

@ -20,6 +20,8 @@ services:
restart: unless-stopped
environment:
- TZ=UTC
volumes:
- betterframe-data:/var/lib/betterframe
expose:
- "18080"
- "18081"
@ -94,6 +96,11 @@ services:
networks:
- betterframe
volumes:
betterframe-data:
nrdata:
pgdata:
networks:
betterframe:
driver: bridge

View file

@ -1,128 +0,0 @@
# BetterFrame stack: server + Angie proxy + Node-RED.
# Kiosk runs on the Pi natively (not in Docker, needs Wayland/HDMI).
#
# Lives at repo root by convention — Docker Compose + Coolify both default
# to looking here. All paths are repo-root-relative so they resolve
# identically whether compose is invoked with or without --project-directory.
#
# Usage:
# docker compose up -d --build # from repo root
#
# Volumes (override per-deployment via env — see Coolify "Environment"):
# BF_DATA_VOLUME_NAME default "betterframe-data"
# NODERED_DATA_VOLUME_NAME default "nodered-data"
# BF_HOST_PORT default 80 (host edge port mapped to angie)
#
# Coolify ops: set these env vars on the resource so each deployment owns its
# own named volumes (e.g. "bf-prod-data" vs "bf-staging-data"). For host bind
# mounts or NFS / S3-CSI volumes, use Coolify's per-service "Storage" UI
# rather than templating driver_opts here — JSON injection via env is brittle.
#
# Only ${BF_HOST_PORT}:80 is published on the host. Backend services and
# Node-RED are reachable only from within the Docker network.
version: "3.8"
services:
server:
build:
context: .
dockerfile: deploy/docker/Dockerfile.server
args:
BF_SERVER_VERSION: ${BF_SERVER_VERSION:-}
container_name: betterframe-server
restart: unless-stopped
environment:
- TZ=UTC
volumes:
- type: bind
source: ${SERVER_SEC_CONFIG:-./sec-config.yaml}
target: /home/bsb/sec-config.yaml
read_only: true
expose:
- "18080"
- "18081"
- "18082"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:18080/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- betterframe
angie:
build:
context: .
dockerfile: deploy/docker/Dockerfile.angie
container_name: betterframe-angie
restart: unless-stopped
depends_on:
- server
- nodered
ports:
- "${BF_HOST_PORT:-80}:80"
networks:
- betterframe
nodered:
build:
context: .
dockerfile: deploy/docker/Dockerfile.nodered
container_name: betterframe-nodered
restart: unless-stopped
environment:
- TZ=UTC
volumes:
- nodered-data:/data
expose:
- "1880"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:1880/nrdp/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 90s
networks:
- betterframe
# Optional: uncomment to use PostgreSQL instead of SQLite.
# Set BF_DB=postgres and BF_PG_URL on the server service to activate.
postgres:
image: postgres:18-alpine
container_name: betterframe-postgres
restart: unless-stopped
environment:
- POSTGRES_DB=${BF_PG_DB:-betterframe}
- POSTGRES_USER=${BF_PG_USER:-betterframe}
- POSTGRES_PASSWORD=${BF_PG_PASSWORD:-betterframe}
volumes:
- postgres-data:/var/lib/postgresql
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${BF_PG_USER:-betterframe}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- betterframe
profiles:
- postgres
volumes:
# Top-level keys are the in-compose references used above. `name:` sets the
# actual docker volume name on the host so multiple Coolify deployments on
# the same host can share machine without name collisions. Default keeps
# backward compat with existing single-host deployments.
betterframe-data:
name: ${BF_DATA_VOLUME_NAME:-betterframe-data}
nodered-data:
name: ${NODERED_DATA_VOLUME_NAME:-nodered-data}
postgres-data:
name: ${BF_PG_VOLUME_NAME:-betterframe-postgres}
networks:
betterframe:
driver: bridge

View file

@ -110,25 +110,27 @@ pub fn get_statuses() -> HashMap<String, SubStatus> {
STATUS.lock().unwrap().clone().unwrap_or_default()
}
/// Start event subscription workers for all ONVIF cameras in the bundle.
/// Idempotent — stops old workers (via ACTIVE flag) before starting new.
/// Start event subscription workers for ONVIF cameras assigned to this kiosk.
/// Only subscribes when event_source is "auto" or "kiosk:<this_kiosk_id>".
pub fn start(
cameras: &[BundleCamera],
cluster_key: Option<&str>,
server_url: &str,
kiosk_key: &str,
) {
// Only subscribe to cameras where event_source is "auto" or "kiosk:<this_id>"
// (not "server" or another kiosk). For "auto", this kiosk subscribes because
// the server put the camera in this kiosk's bundle — meaning it's reachable.
let my_kiosk_id = crate::server::load_kiosk_id();
let onvif_cams: Vec<_> = cameras
.iter()
.filter(|c| {
if c.cam_type != "onvif" || c.onvif_host.is_none() { return false; }
match c.event_source.as_deref() {
Some("server") => false, // server handles this one
Some(s) if s.starts_with("kiosk:") => true, // pinned to a kiosk (might be us)
_ => true, // "auto" or missing → this kiosk subscribes
Some("server") => false,
Some("none") | Some("disabled") => false,
Some(s) if s.starts_with("kiosk:") => {
let assigned = &s[6..];
my_kiosk_id.as_deref() == Some(assigned)
}
_ => true, // "auto" or missing
}
})
.cloned()
@ -180,30 +182,32 @@ fn run_subscription(
let has_pass = !pass.is_empty();
info!("onvif-events: cam {} ({}) subscribing at {event_url} user={user} has_pass={has_pass}", cam.id, cam.name);
let mut backoff_secs: u64 = 30;
loop {
if generation.upgrade().is_none() {
info!("onvif-events: cam {} generation expired, exiting", cam.id);
return;
}
// 1. CreatePullPointSubscription
set_status(&cam.id, "subscribing", None);
let sub = match create_pullpoint(&event_url, user, pass) {
Ok(s) => s,
Err(e) => {
warn!("onvif-events: cam {} CreatePullPoint failed: {e}", cam.id);
warn!("onvif-events: cam {} CreatePullPoint failed: {e} (backoff {backoff_secs}s)", cam.id);
set_status(&cam.id, "failed", Some(e));
std::thread::sleep(Duration::from_secs(30));
std::thread::sleep(Duration::from_secs(backoff_secs));
backoff_secs = (backoff_secs * 2).min(600);
continue;
}
};
backoff_secs = 30;
info!("onvif-events: cam {} subscribed, address={}", cam.id, sub.address);
set_status(&cam.id, "active", None);
// 2. Poll loop
let poll_interval = Duration::from_secs(3);
let renew_interval = Duration::from_secs(55); // renew before 60s timeout
let poll_interval = Duration::from_secs(10);
let renew_interval = Duration::from_secs(55);
let mut since_renew = std::time::Instant::now();
let mut consecutive_errors: u32 = 0;
loop {
if generation.upgrade().is_none() {
@ -224,16 +228,22 @@ fn run_subscription(
match pull_messages(&sub.address, user, pass) {
Ok(events) => {
consecutive_errors = 0;
for evt in events {
forward_event(server, kiosk_key, &cam.id, &evt, user, pass);
mark_event_received(&cam.id);
}
}
Err(e) => {
warn!("onvif-events: cam {} pull failed: {e}", cam.id);
consecutive_errors += 1;
let error_backoff = (15 * consecutive_errors as u64).min(300);
warn!("onvif-events: cam {} pull failed ({consecutive_errors}x): {e}, backoff {error_backoff}s", cam.id);
set_status(&cam.id, "failed", Some(e));
std::thread::sleep(Duration::from_secs(15));
break; // resubscribe after backoff
if consecutive_errors >= 5 {
break; // resubscribe from scratch
}
std::thread::sleep(Duration::from_secs(error_backoff));
continue;
}
}

View file

@ -162,6 +162,10 @@ pub fn load_cached_bundle() -> Option<KioskBundle> {
}
}
pub fn load_kiosk_id() -> Option<String> {
load_cached_bundle().map(|b| b.kiosk_id)
}
/// Discover the BetterFrame server.
pub fn discover_server(override_url: Option<&str>) -> String {
if let Some(url) = override_url {

View file

@ -65,12 +65,8 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
const tenant = await deps.repo.getTenantBySlug(tenantSlug);
if (tenant && tenant.is_active) {
event.context.tenant = tenant;
// Set PG search_path to the tenant's schema.
if (tenant.schema_name !== "public") {
await deps.repo.adapter.setSearchPath(tenant.schema_name);
}
await deps.repo.adapter.setSearchPath(tenant.schema_name);
} else {
// Fall back to default tenant.
const defaultTenant = await deps.repo.getTenantBySlug("default");
if (defaultTenant) {
event.context.tenant = defaultTenant;

View file

@ -6,7 +6,7 @@ import { type H3, getRouterParam, readBody, createError } from "h3";
import { htmlPage } from "./html-response.js";
import type { AdminDeps } from "./index.js";
import * as ablesign from "../../shared/ablesign.js";
import { AbleSignPage, AbleSignScreensPage } from "../../web-templates/admin-pages.js";
import { AbleSignPage, AbleSignScreensPage, AbleSignScreenDetailPage, AbleSignContentPage, AbleSignPlaylistsPage } from "../../web-templates/admin-pages.js";
export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
@ -33,17 +33,34 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
}
const encrypted = deps.secrets.encryptString(apiKey, "ablesign-key");
await deps.repo.createAbleSignAccount({ name, api_key_encrypted: encrypted, workspace_id: workspaceId });
return new Response(null, { status: 302, headers: { location: "/admin/ablesign" } });
const accountId = await deps.repo.createAbleSignAccount({ name, api_key_encrypted: encrypted, workspace_id: workspaceId });
// Auto-sync screens on account creation.
try {
const opts = { apiKey, workspaceId };
const result = await ablesign.listScreens(opts);
for (const s of result.data) {
await deps.repo.upsertAbleSignScreen({
account_id: accountId,
ablesign_screen_id: String(s.id),
title: s.title,
online: !!s.heartbeatTime,
last_heartbeat_at: s.heartbeatTime || undefined,
orientation: s.orientation,
});
}
await deps.repo.updateAbleSignAccount(accountId, {
screen_count: result.data.length,
last_sync_at: new Date().toISOString(),
});
} catch { /* sync failure is non-fatal */ }
return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
});
app.get("/admin/ablesign/:id/screens", async (event) => {
const id = getRouterParam(event, "id") ?? "";
const account = await deps.repo.getAbleSignAccount(id);
if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" });
const screens = await deps.repo.listAbleSignScreens(id);
const kiosks = await deps.repo.listKiosks();
return htmlPage(AbleSignScreensPage({ account, screens, kiosks }));
// Redirect old per-account route to global screens page.
app.get("/admin/ablesign/:id/screens", async () => {
return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
});
app.post("/admin/ablesign/:id/sync", async (event) => {
@ -57,7 +74,7 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
const result = await ablesign.listScreens(opts);
for (const s of result.data) {
await deps.repo.upsertAbleSignScreen({
const screenRowId = await deps.repo.upsertAbleSignScreen({
account_id: id,
ablesign_screen_id: String(s.id),
title: s.title,
@ -90,7 +107,7 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
const body = await readBody<Record<string, string>>(event);
const title = (body?.title ?? "").trim();
if (!title) {
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
}
try {
@ -105,18 +122,19 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
screenToken = poll.screenToken;
} catch { /* token may not be ready yet — kiosk can work without it initially */ }
const screenTitle = screen.title || title;
const screenRowId = await deps.repo.createAbleSignScreen({
account_id: accountId,
ablesign_screen_id: String(screen.id),
ablesign_screen_token_encrypted: screenToken
? deps.secrets.encryptString(screenToken, "ablesign-token")
: undefined,
title: screen.title,
orientation: screen.orientation,
title: screenTitle,
orientation: screen.orientation || "landscape",
});
await deps.repo.createEntity({
name: `AbleSign: ${screen.title}`,
name: `AbleSign: ${screenTitle}`,
type: "ablesign",
description: `AbleSign screen (ID: ${String(screen.id)})`,
web_url: "https://player.ablesign.tv",
@ -127,11 +145,15 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
await deps.repo.updateAbleSignAccount(accountId, {
screen_count: (account.screen_count ?? 0) + 1,
});
} catch {
// redirect back — error handling TODO
} catch (err) {
const msg = (err as Error).message ?? "unknown error";
event.context.obs?.log.warn("ablesign screen creation failed: {msg}", { msg });
const screens = await deps.repo.listAbleSignScreens(accountId);
for (const s of screens) (s as any).has_entity = !!(await deps.repo.getEntityByAbleSignScreen(s.id));
return htmlPage(AbleSignScreensPage({ screens, accountId, error: `Screen creation failed: ${msg}` }));
}
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
});
app.post("/admin/ablesign/screens/:sid/assign", async (event) => {
@ -142,13 +164,61 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
const screen = await deps.repo.getAbleSignScreen(sid);
const accountId = screen?.account_id ?? "";
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
});
// ---- Screen detail + config -------------------------------------------------
app.get("/admin/ablesign/screens/:sid", async (event) => {
const sid = getRouterParam(event, "sid") ?? "";
const screen = await deps.repo.getAbleSignScreen(sid);
if (!screen) throw createError({ statusCode: 404, statusMessage: "Screen not found" });
const account = await deps.repo.getAbleSignAccount(screen.account_id);
let remoteScreen: any = null;
if (account) {
try {
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
remoteScreen = await ablesign.getScreen(
{ apiKey, workspaceId: account.workspace_id || undefined },
Number(screen.ablesign_screen_id),
);
} catch { /* remote fetch failed */ }
}
const entity = await deps.repo.getEntityByAbleSignScreen(sid);
return htmlPage(AbleSignScreenDetailPage({ screen, remoteScreen, entity }));
});
app.post("/admin/ablesign/screens/:sid", async (event) => {
const sid = getRouterParam(event, "sid") ?? "";
const screen = await deps.repo.getAbleSignScreen(sid);
if (!screen) throw createError({ statusCode: 404, statusMessage: "Screen not found" });
const account = await deps.repo.getAbleSignAccount(screen.account_id);
if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" });
const body = await readBody<Record<string, string>>(event);
const title = (body?.title ?? "").trim();
const orientation = body?.orientation ?? "landscape";
const description = (body?.description ?? "").trim();
try {
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
await ablesign.updateScreen(
{ apiKey, workspaceId: account.workspace_id || undefined },
Number(screen.ablesign_screen_id),
{ title: title || undefined, orientation, description: description || undefined },
);
if (title) {
await deps.repo.updateAbleSignScreen(sid, { title, orientation });
}
} catch { /* update failed */ }
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/screens/${sid}` } });
});
app.post("/admin/ablesign/:id/delete", async (event) => {
const id = getRouterParam(event, "id") ?? "";
await deps.repo.deleteAbleSignAccount(id);
return new Response(null, { status: 302, headers: { location: "/admin/ablesign" } });
return new Response(null, { status: 302, headers: { location: "/admin/settings" } });
});
app.post("/admin/ablesign/screens/:sid/delete", async (event) => {
@ -168,6 +238,51 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
await deps.repo.deleteAbleSignScreen(sid);
}
const accountId = screen?.account_id ?? "";
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } });
return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
});
// ---- Global views (all accounts aggregated) --------------------------------
app.get("/admin/ablesign/screens", async () => {
const accounts = await deps.repo.listAbleSignAccounts();
const account = accounts[0] ?? null;
const screens = account ? await deps.repo.listAbleSignScreens(account.id) : [];
for (const s of screens) {
(s as any).has_entity = !!(await deps.repo.getEntityByAbleSignScreen(s.id));
}
return htmlPage(AbleSignScreensPage({ screens, accountId: account?.id ?? null }));
});
app.get("/admin/ablesign/content", async () => {
const accounts = await deps.repo.listAbleSignAccounts();
const content: any[] = [];
for (const acct of accounts) {
try {
const apiKey = deps.secrets.decryptString(acct.api_key_encrypted, "ablesign-key");
const opts = { apiKey, workspaceId: acct.workspace_id || undefined };
const media = await ablesign.listMediaFiles(opts);
const webApps = await ablesign.listWebApps(opts);
for (const m of media.data) content.push({ ...m, account_name: acct.name, kind: "media" });
for (const w of webApps.data) content.push({ ...w, account_name: acct.name, kind: "webapp" });
} catch { /* skip failed accounts */ }
}
return htmlPage(AbleSignContentPage({ content, accounts }));
});
app.get("/admin/ablesign/playlists", async () => {
const accounts = await deps.repo.listAbleSignAccounts();
const screens = await deps.repo.listAbleSignScreens();
const playlists: any[] = [];
for (const s of screens) {
const acct = accounts.find((a: any) => a.id === s.account_id);
if (!acct) continue;
try {
const apiKey = deps.secrets.decryptString(acct.api_key_encrypted, "ablesign-key");
const opts = { apiKey, workspaceId: acct.workspace_id || undefined };
const pl = await ablesign.getPlaylist(opts, Number(s.ablesign_screen_id));
playlists.push({ screen_title: s.title, account_name: acct.name, ...pl });
} catch { /* skip */ }
}
return htmlPage(AbleSignPlaylistsPage({ playlists }));
});
}

View file

@ -34,6 +34,7 @@ import {
renderKioskLabels,
renderDisplayLayouts,
renderDefaultLayoutSelect,
SettingsPage,
} from "../../web-templates/admin-pages.js";
import { discover as onvifDiscover, getEventProperties as onvifGetEventProperties } from "../../shared/onvif.js";
import { generateBundle } from "../../shared/bundle.js";
@ -2219,6 +2220,29 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
// ---- Settings page ----------------------------------------------------------
app.get("/admin/settings", async () => {
const cloudAccounts = await deps.repo.listCloudAccounts();
const ablesignAccounts = await deps.repo.listAbleSignAccounts();
return htmlPage(SettingsPage({ cloudAccounts, ablesignAccounts }));
});
// ---- Tenant switcher fragment (htmx) ----------------------------------------
app.get("/admin/_tenant_switcher", async (event) => {
const tenants = await deps.repo.listTenants();
if (tenants.length <= 1) return new Response("", { headers: { "content-type": "text/html" } });
const current = (event.context as any).tenant?.slug ?? "default";
const options = tenants.map((t: any) =>
`<option value="${t.slug as string}"${t.slug === current ? " selected" : ""}>${t.name as string}</option>`
).join("");
const html = `<form method="post" action="/admin/tenants/switch" style="padding:0.5rem 1rem">
<label style="font-size:0.75rem; color:#888; display:block; margin-bottom:0.25rem">Tenant</label>
<select name="tenant_slug" style="width:100%; font-size:0.8rem; padding:0.25rem" onchange="this.form.submit()">${options}</select>
</form>`;
return new Response(html, { headers: { "content-type": "text/html" } });
});
// ---- JSON API (admin scope) — used by Node-RED bf-* nodes ---------------
//
// All payloads run through `stripSecrets` so credential-bearing fields

View file

@ -302,6 +302,16 @@ export async function generateBundle(
});
}
// ONVIF event ownership: for "auto" cameras, first kiosk to fetch bundle
// takes ownership. Server writes "kiosk:<id>" into event_source so
// subsequent kiosks see it's taken and skip.
for (const cam of cameras) {
if (cam.type === "onvif" && cam.event_source === "auto") {
await repo.updateCamera(cam.id, { event_source: `kiosk:${kioskId}` } as any);
cam.event_source = `kiosk:${kioskId}`;
}
}
const bundleCameras: BundleCamera[] = [];
for (const cam of cameras) {
const streams = await repo.listCameraStreams(cam.id);

View file

@ -13,9 +13,9 @@ import type { DbAdapter, RunResult, Row, SqlValue } from "./db-adapter.js";
export class PgAdapter implements DbAdapter {
private readonly pool: Pool;
/** Per-async-context client when inside transaction(). */
private currentTxClient: PoolClient | null = null;
private txDepth = 0;
private searchPath = "public";
constructor(connectionString: string, poolMax: number = 10) {
this.pool = new Pool({
@ -70,8 +70,12 @@ export class PgAdapter implements DbAdapter {
private async runner<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> {
if (this.currentTxClient) return fn(this.currentTxClient);
const client = await this.pool.connect();
try { return await fn(client); }
finally { client.release(); }
try {
await client.query(`SET search_path TO ${this.searchPath}, public`);
return await fn(client);
} finally {
client.release();
}
}
async run(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<RunResult> {
@ -149,13 +153,10 @@ export class PgAdapter implements DbAdapter {
dialect(): "postgres" { return "postgres"; }
async setSearchPath(schema: string): Promise<void> {
// Validate schema name to prevent SQL injection (only allow alphanumeric + underscore).
if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) {
throw new Error(`invalid schema name: ${schema}`);
}
await this.runner(async (c) => {
await c.query(`SET search_path TO ${schema}, public`);
});
this.searchPath = schema;
}
async close(): Promise<void> {

View file

@ -260,7 +260,24 @@ export class Repository {
}
async isSetupComplete(): Promise<boolean> {
return (await this.getSetupState()).is_complete && (await this.countUsers()) > 0;
const state = await this.getSetupState();
if (state.is_complete) return true;
if ((await this.countUsers()) > 0) return true;
// No local users — copy global admin into tenant if one exists.
const ga = await this._get<{ id: string; username: string; password_hash: string }>(
"SELECT id, username, password_hash FROM public.global_admins WHERE is_active = true LIMIT 1",
).catch(() => undefined);
if (ga) {
await this._run(
`INSERT INTO users (id, username, password_hash, role, is_active)
VALUES (?, ?, ?, 'admin', true)
ON CONFLICT (id) DO NOTHING`,
[ga.id, ga.username, ga.password_hash],
);
await this.markSetupComplete();
return true;
}
return false;
}
async markSetupComplete(): Promise<void> {
@ -2402,6 +2419,14 @@ export class Repository {
* the camera's name is already taken by another entity, append the camera
* id to keep the name unique.
*/
async getEntityByAbleSignScreen(screenId: string): Promise<Entity | null> {
const r = await this._get(
`SELECT * FROM entities WHERE type = 'ablesign' AND ablesign_screen_id = ? LIMIT 1`,
[screenId],
);
return r ? rowToEntity(r as Record<string, unknown>) : null;
}
async ensureCameraEntity(camera: Camera): Promise<Entity> {
const existing = await this.getEntityForCamera(camera.id);
if (existing) return existing;
@ -2435,6 +2460,7 @@ export class Repository {
await this._run(`DELETE FROM displays WHERE kiosk_id = ?`, [id]);
await this._run(`DELETE FROM kiosk_labels WHERE kiosk_id = ?`, [id]);
await this._run(`DELETE FROM kiosk_gpio_bindings WHERE kiosk_id = ?`, [id]);
await this._run(`UPDATE cameras SET event_source = 'auto' WHERE event_source = ?`, [`kiosk:${id}`]);
await this._run(`DELETE FROM kiosks WHERE id = ?`, [id]);
});
for (const display of displays) {
@ -2529,6 +2555,18 @@ export class Repository {
// camera_event_subscriptions
// ===========================================================================
async getActiveOnvifOwners(): Promise<Map<string, string>> {
const rs = await this._all<{ camera_id: string; subscribed_by_kiosk_id: string }>(
`SELECT DISTINCT camera_id, subscribed_by_kiosk_id FROM camera_event_subscriptions
WHERE subscribed_by_kiosk_id IS NOT NULL AND status = 'active'`,
);
const map = new Map<string, string>();
for (const r of rs) {
map.set(r.camera_id, r.subscribed_by_kiosk_id);
}
return map;
}
async listEventSubscriptions(cameraId: string): Promise<CameraEventSubscription[]> {
const rs = await this._all(
"SELECT * FROM camera_event_subscriptions WHERE camera_id = ? ORDER BY topic",

View file

@ -130,9 +130,9 @@ function loadServerKey(config: SecretsConfig, log: SecretsLog): Buffer {
}
}
// 3. Generate new dev key
log.warn(
`GENERATING DEV SERVER KEY at ${keyPath} — production should use systemd-creds`,
// 3. No key found — generate one and persist.
log.info(
`encryption key not found, generating new key at ${keyPath}`,
);
try {
mkdirSync(dirname(keyPath), { recursive: true });

View file

@ -4380,6 +4380,73 @@ export function TenantEditPage(props: TenantEditPageProps) {
);
}
// ---- Settings Page ----------------------------------------------------------
interface SettingsPageProps {
cloudAccounts: any[];
ablesignAccounts: any[];
error?: string;
}
export function SettingsPage(props: SettingsPageProps) {
return (
<Layout title="Settings" activeNav="settings">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">Settings</h1>
{props.error ? <div class="alert alert-error" style="margin-bottom:1rem">{props.error}</div> : ""}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1.1rem; margin:0 0 1rem">AbleSign Account</h2>
{props.ablesignAccounts.length > 0 ? (
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Screens</th><th>Last Sync</th><th>Actions</th></tr></thead>
<tbody>
{props.ablesignAccounts.map((a: any) => (
<tr>
<td>{a.name}</td>
<td>{String(a.screen_count ?? 0)}</td>
<td style="font-size:0.85rem">{a.last_sync_at ? formatTime(a.last_sync_at) : "Never"}</td>
<td>
<form method="POST" action={`/admin/ablesign/${String(a.id)}/delete`} style="display:inline">
<button type="submit" class="btn btn-sm btn-ghost" style="color:#c00">Remove</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<form method="POST" action="/admin/ablesign/add" style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:end">
<label style="font-size:0.85rem">
{"Name"}<br/>
<input type="text" name="name" required style="width:10rem" placeholder="My AbleSign" />
</label>
<label style="font-size:0.85rem">
{"API Key"}<br/>
<input type="password" name="api_key" required style="width:14rem" placeholder="ak_..." />
</label>
<label style="font-size:0.85rem">
{"Workspace ID"}<br/>
<input type="text" name="workspace_id" style="width:6rem" />
</label>
<button type="submit" class="btn btn-sm">Connect</button>
</form>
)}
</div>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1.1rem; margin:0 0 1rem">Cloud Camera Accounts</h2>
<p style="font-size:0.85rem; color:#999">
{"Manage cloud camera integrations at "}
<a href="/admin/cloud-accounts">Cloud Cams</a>.
</p>
</div>
</Layout>
);
}
// ---- AbleSign Pages ---------------------------------------------------------
interface AbleSignPageProps {
@ -4453,45 +4520,51 @@ export function AbleSignPage(props: AbleSignPageProps) {
}
interface AbleSignScreensPageProps {
account: any;
screens: any[];
kiosks: any[];
accountId: string | null;
error?: string;
}
export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
const a = props.account;
const aid = props.accountId;
return (
<Layout title={`AbleSign — ${String(a.name)}`} activeNav="ablesign">
<h1 style="font-size:1.5rem; margin:0 0 0.5rem">{a.name} Screens</h1>
<p style="color:#999; margin:0 0 1.5rem; font-size:0.85rem">
{String(a.screen_count ?? 0)} screens
{a.last_sync_at && ` · synced ${formatTime(a.last_sync_at)}`}
</p>
<Layout title="AbleSign — Screens" activeNav="ablesign-screens">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Screens</h1>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.75rem">Add Screen</h2>
<form method="POST" action={`/admin/ablesign/${String(a.id)}/screens/add`} style="display:flex; gap:0.5rem; align-items:end">
<label style="font-size:0.85rem">
{"Screen Name"}<br/>
<input type="text" name="title" required style="width:16rem" placeholder="Lobby Display" />
</label>
<button type="submit" class="btn btn-sm">{"Create & Pair"}</button>
</form>
<p style="font-size:0.8rem; color:#999; margin:0.5rem 0 0">
Creates a new screen in AbleSign and pairs it automatically.
</p>
</div>
{props.error ? <div class="alert alert-error" style="margin-bottom:1rem">{props.error}</div> : ""}
{!aid ? (
<div class="card" style="margin-bottom:1.5rem">
<p style="color:#999; font-size:0.85rem">No AbleSign account configured. Add one under Account settings first.</p>
</div>
) : (
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.75rem">Create Screen</h2>
<form method="POST" action={`/admin/ablesign/${aid}/screens/add`} style="display:flex; gap:0.5rem; align-items:end">
<label style="font-size:0.85rem">
{"Screen Name"}<br/>
<input type="text" name="title" required style="width:16rem" placeholder="Lobby Display" />
</label>
<button type="submit" class="btn btn-sm">{"Create & Pair"}</button>
</form>
<p style="font-size:0.8rem; color:#999; margin:0.5rem 0 0">
Registers a new screen in AbleSign headlessly and creates a linked entity for use in layouts.
</p>
</div>
)}
<div class="card" style="margin-bottom:1rem">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem">
<h2 style="font-size:1rem; margin:0">Screens</h2>
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`}>
<button type="submit" class="btn btn-sm btn-ghost">Sync from AbleSign</button>
</form>
{aid ? (
<form method="POST" action={`/admin/ablesign/${aid}/sync`}>
<button type="submit" class="btn btn-sm btn-ghost">Sync from AbleSign</button>
</form>
) : ""}
</div>
{props.screens.length === 0 ? (
<p style="color:#999; font-size:0.85rem">No screens yet. Add one above or sync from AbleSign.</p>
<p style="color:#999; font-size:0.85rem">No screens yet. Create one above or sync from AbleSign.</p>
) : (
<div class="table-wrap">
<table>
@ -4499,13 +4572,13 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
<th>Title</th>
<th>Orientation</th>
<th>Status</th>
<th>Assigned Kiosk</th>
<th>Source</th>
<th>Actions</th>
</tr></thead>
<tbody>
{props.screens.map((s: any) => (
<tr>
<td>{s.title}</td>
<td><a href={`/admin/ablesign/screens/${String(s.id)}`}>{s.title}</a></td>
<td style="font-size:0.85rem">{s.orientation}</td>
<td>
{s.online
@ -4513,16 +4586,9 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
: <span class="badge badge-gray">Offline</span>}
</td>
<td>
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/assign`}
style="display:flex; gap:0.25rem; align-items:center">
<select name="kiosk_id" style="font-size:0.85rem; max-width:14rem">
<option value=""> None </option>
{props.kiosks.map((k: any) => (
<option value={String(k.id)} selected={k.id === s.kiosk_id}>{k.name}</option>
))}
</select>
<button type="submit" class="btn btn-sm btn-ghost">Assign</button>
</form>
{s.has_entity
? <span class="badge badge-blue">Internal</span>
: <span class="badge badge-gray">External</span>}
</td>
<td>
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/delete`} style="display:inline">
@ -4539,3 +4605,117 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
</Layout>
);
}
// ---- AbleSign Screen Detail Page ---------------------------------------------
interface AbleSignScreenDetailPageProps {
screen: any;
remoteScreen: any | null;
entity: any | null;
}
export function AbleSignScreenDetailPage(props: AbleSignScreenDetailPageProps) {
const s = props.screen;
const r = props.remoteScreen;
return (
<Layout title={`AbleSign — ${String(s.title)}`} activeNav="ablesign-screens">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">{s.title}</h1>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.75rem">Screen Configuration</h2>
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}`}>
<div style="display:flex; gap:1rem; flex-wrap:wrap; margin-bottom:1rem">
<label style="font-size:0.85rem">
{"Title"}<br/>
<input type="text" name="title" value={s.title} style="width:16rem" />
</label>
<label style="font-size:0.85rem">
{"Orientation"}<br/>
<select name="orientation" style="font-size:0.85rem">
<option value="landscape" selected={s.orientation === "landscape"}>Landscape</option>
<option value="portrait" selected={s.orientation === "portrait"}>Portrait</option>
</select>
</label>
<label style="font-size:0.85rem">
{"Description"}<br/>
<input type="text" name="description" value={r?.description ?? ""} style="width:20rem" placeholder="Optional" />
</label>
</div>
<button type="submit" class="btn btn-sm">Save</button>
</form>
</div>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.5rem">Status</h2>
<div style="display:flex; gap:1.5rem; flex-wrap:wrap; font-size:0.85rem; color:#666">
<div>{"AbleSign ID: "}{String(s.ablesign_screen_id)}</div>
<div>{"Status: "}{s.online ? "Online" : "Offline"}</div>
<div>{"Source: "}{props.entity ? "Internal" : "External"}</div>
{props.entity ? <div>{"Entity: "}<a href={`/admin/entities/${String(props.entity.id)}`}>{props.entity.name}</a></div> : ""}
{r?.heartbeatTime ? <div>{"Last heartbeat: "}{formatTime(r.heartbeatTime)}</div> : ""}
{r?.timezone ? <div>{"Timezone: "}{String(r.timezone)}</div> : ""}
</div>
</div>
<div style="display:flex; gap:0.5rem">
<a href="/admin/ablesign/screens" class="btn btn-sm btn-ghost">Back to Screens</a>
</div>
</Layout>
);
}
interface AbleSignContentPageProps { content: any[]; accounts: any[]; }
export function AbleSignContentPage(props: AbleSignContentPageProps) {
return (
<Layout title="AbleSign — Content" activeNav="ablesign-content">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Content</h1>
<div class="card">
{props.content.length === 0
? <p style="color:#999; font-size:0.85rem">No content found. Add media or web apps in AbleSign CMS.</p>
: <div class="table-wrap">
<table>
<thead><tr><th>Title</th><th>Type</th><th>Account</th></tr></thead>
<tbody>
{props.content.map((c: any) => (
<tr>
<td>{c.title}</td>
<td style="font-size:0.85rem">{c.kind === "media" ? String(c.fileType || "media") : "web app"}</td>
<td style="font-size:0.85rem; color:#999">{c.account_name}</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
</Layout>
);
}
interface AbleSignPlaylistsPageProps { playlists: any[]; }
export function AbleSignPlaylistsPage(props: AbleSignPlaylistsPageProps) {
const cards = props.playlists.map((pl: any) =>
`<div class="card" style="margin-bottom:1rem">
<h2 style="font-size:1rem; margin:0 0 0.5rem">${pl.screen_title as string}</h2>
<p style="font-size:0.8rem; color:#999; margin:0 0 0.5rem">
Account: ${pl.account_name as string} · ${String(pl.items?.length ?? 0)} items${pl.shufflePlay ? " · Shuffle" : ""}
</p>
${Array.isArray(pl.items) && pl.items.length > 0
? `<table style="font-size:0.85rem; width:100%"><thead><tr><th>#</th><th>Type</th><th>Duration</th></tr></thead><tbody>${
(pl.items as any[]).map((item: any, idx: number) =>
`<tr><td>${String(idx + 1)}</td><td>${item.mediafileId ? "Media" : item.webAppId ? "Web App" : "Unknown"}</td><td>${item.displayDuration ? `${String(item.displayDuration)}s` : "—"}</td></tr>`
).join("")
}</tbody></table>`
: ""}
</div>`
).join("");
return (
<Layout title="AbleSign — Playlists" activeNav="ablesign-playlists">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Playlists</h1>
{props.playlists.length === 0
? <div class="card"><p style="color:#999; font-size:0.85rem">No playlists found.</p></div>
: cards}
</Layout>
);
}

View file

@ -39,6 +39,20 @@ function NavItem(props: { href: string; label: string; icon: string; active?: bo
);
}
function NavGroup(props: { label: string; icon: string; active?: boolean; children: string | string[] }) {
return (
<details class="nav-group" open={props.active}>
<summary class={`nav-item${props.active ? " active" : ""}`}>
<span class="nav-icon">{props.icon}</span>
{props.label}
</summary>
<div class="nav-group-items">
{props.children}
</div>
</details>
);
}
function Sidebar(props: { activeNav?: string }) {
const a = props.activeNav;
return (
@ -57,15 +71,20 @@ function Sidebar(props: { activeNav?: string }) {
<NavItem href="/admin/kiosks" label="Kiosks" icon="&#9672;" active={a === "kiosks"} />
<NavItem href="/admin/firmware" label="Firmware" icon="&#9650;" active={a === "firmware"} />
<NavItem href="/admin/os-updates" label="OS Updates" icon="&#9679;" active={a === "os-updates"} />
<NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="&#9729;" active={a === "cloud"} />
<NavItem href="/admin/ablesign" label="AbleSign" icon="&#9654;" active={a === "ablesign"} />
<NavGroup label="AbleSign" icon="&#9654;" active={a?.startsWith("ablesign")}>
<NavItem href="/admin/ablesign/screens" label="Screens" icon=" " active={a === "ablesign-screens"} />
<NavItem href="/admin/ablesign/content" label="Content" icon=" " active={a === "ablesign-content"} />
<NavItem href="/admin/ablesign/playlists" label="Playlists" icon=" " active={a === "ablesign-playlists"} />
</NavGroup>
<NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} />
<NavItem href="/admin/audit" label="Audit" icon="&#9678;" active={a === "audit"} />
<NavItem href="/admin/backup" label="Backup" icon="&#9788;" active={a === "backup"} />
<NavItem href="/admin/tenants" label="Tenants" icon="&#9783;" active={a === "tenants"} />
<hr />
<NavItem href="/admin/settings" label="Settings" icon="&#9881;" active={a === "settings"} />
<NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} />
<NavItem href="/admin/nodered" label="Node-RED" icon="&#8594;" active={a === "nodered"} />
<div class="tenant-switcher" {...{"hx-get": "/admin/_tenant_switcher", "hx-trigger": "load", "hx-swap": "innerHTML"}}></div>
</nav>
</aside>
);
@ -200,6 +219,12 @@ const baseStyles = {
".nav-item:hover": { backgroundColor: "#2a2a4e", color: "#fff", textDecoration: "none" },
".nav-item.active": { backgroundColor: "#2563eb", color: "#fff" },
".nav-icon": { fontSize: "0.75rem", width: "1.25rem", textAlign: "center" as const },
".nav-group": { margin: 0, padding: 0 },
".nav-group summary": { cursor: "pointer", listStyle: "none" },
".nav-group summary::-webkit-details-marker": { display: "none" },
".nav-group-items": { paddingLeft: "1.25rem" },
".nav-group-items .nav-item": { fontSize: "0.8rem", padding: "0.35rem 1rem" },
".tenant-switcher": { marginTop: "auto", borderTop: "1px solid #2a2a4e", paddingTop: "0.25rem" },
".sidebar hr": { border: "none", borderTop: "1px solid #2a2a4e", margin: "0.5rem 0" },
".topbar": {
display: "flex",