mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-27 00:36:34 +00:00
Compare commits
6 commits
v0.0.131-d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f5cf7fac | ||
|
|
1dbb56752c | ||
|
|
65de42d495 | ||
|
|
e0941f533d | ||
|
|
e1a3cd1d05 | ||
|
|
a518fe17ea |
11 changed files with 438 additions and 118 deletions
|
|
@ -110,25 +110,27 @@ pub fn get_statuses() -> HashMap<String, SubStatus> {
|
||||||
STATUS.lock().unwrap().clone().unwrap_or_default()
|
STATUS.lock().unwrap().clone().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start event subscription workers for all ONVIF cameras in the bundle.
|
/// Start event subscription workers for ONVIF cameras assigned to this kiosk.
|
||||||
/// Idempotent — stops old workers (via ACTIVE flag) before starting new.
|
/// Only subscribes when event_source is "auto" or "kiosk:<this_kiosk_id>".
|
||||||
pub fn start(
|
pub fn start(
|
||||||
cameras: &[BundleCamera],
|
cameras: &[BundleCamera],
|
||||||
cluster_key: Option<&str>,
|
cluster_key: Option<&str>,
|
||||||
server_url: &str,
|
server_url: &str,
|
||||||
kiosk_key: &str,
|
kiosk_key: &str,
|
||||||
) {
|
) {
|
||||||
// Only subscribe to cameras where event_source is "auto" or "kiosk:<this_id>"
|
let my_kiosk_id = crate::server::load_kiosk_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 onvif_cams: Vec<_> = cameras
|
let onvif_cams: Vec<_> = cameras
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|c| {
|
.filter(|c| {
|
||||||
if c.cam_type != "onvif" || c.onvif_host.is_none() { return false; }
|
if c.cam_type != "onvif" || c.onvif_host.is_none() { return false; }
|
||||||
match c.event_source.as_deref() {
|
match c.event_source.as_deref() {
|
||||||
Some("server") => false, // server handles this one
|
Some("server") => false,
|
||||||
Some(s) if s.starts_with("kiosk:") => true, // pinned to a kiosk (might be us)
|
Some("none") | Some("disabled") => false,
|
||||||
_ => true, // "auto" or missing → this kiosk subscribes
|
Some(s) if s.starts_with("kiosk:") => {
|
||||||
|
let assigned = &s[6..];
|
||||||
|
my_kiosk_id.as_deref() == Some(assigned)
|
||||||
|
}
|
||||||
|
_ => true, // "auto" or missing
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
|
|
@ -180,30 +182,32 @@ fn run_subscription(
|
||||||
let has_pass = !pass.is_empty();
|
let has_pass = !pass.is_empty();
|
||||||
info!("onvif-events: cam {} ({}) subscribing at {event_url} user={user} has_pass={has_pass}", cam.id, cam.name);
|
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 {
|
loop {
|
||||||
if generation.upgrade().is_none() {
|
if generation.upgrade().is_none() {
|
||||||
info!("onvif-events: cam {} generation expired, exiting", cam.id);
|
info!("onvif-events: cam {} generation expired, exiting", cam.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. CreatePullPointSubscription
|
|
||||||
set_status(&cam.id, "subscribing", None);
|
set_status(&cam.id, "subscribing", None);
|
||||||
let sub = match create_pullpoint(&event_url, user, pass) {
|
let sub = match create_pullpoint(&event_url, user, pass) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
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));
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
backoff_secs = 30;
|
||||||
info!("onvif-events: cam {} subscribed, address={}", cam.id, sub.address);
|
info!("onvif-events: cam {} subscribed, address={}", cam.id, sub.address);
|
||||||
set_status(&cam.id, "active", None);
|
set_status(&cam.id, "active", None);
|
||||||
|
|
||||||
// 2. Poll loop
|
let poll_interval = Duration::from_secs(10);
|
||||||
let poll_interval = Duration::from_secs(3);
|
let renew_interval = Duration::from_secs(55);
|
||||||
let renew_interval = Duration::from_secs(55); // renew before 60s timeout
|
|
||||||
let mut since_renew = std::time::Instant::now();
|
let mut since_renew = std::time::Instant::now();
|
||||||
|
let mut consecutive_errors: u32 = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if generation.upgrade().is_none() {
|
if generation.upgrade().is_none() {
|
||||||
|
|
@ -224,16 +228,22 @@ fn run_subscription(
|
||||||
|
|
||||||
match pull_messages(&sub.address, user, pass) {
|
match pull_messages(&sub.address, user, pass) {
|
||||||
Ok(events) => {
|
Ok(events) => {
|
||||||
|
consecutive_errors = 0;
|
||||||
for evt in events {
|
for evt in events {
|
||||||
forward_event(server, kiosk_key, &cam.id, &evt, user, pass);
|
forward_event(server, kiosk_key, &cam.id, &evt, user, pass);
|
||||||
mark_event_received(&cam.id);
|
mark_event_received(&cam.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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));
|
set_status(&cam.id, "failed", Some(e));
|
||||||
std::thread::sleep(Duration::from_secs(15));
|
if consecutive_errors >= 5 {
|
||||||
break; // resubscribe after backoff
|
break; // resubscribe from scratch
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_secs(error_backoff));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Discover the BetterFrame server.
|
||||||
pub fn discover_server(override_url: Option<&str>) -> String {
|
pub fn discover_server(override_url: Option<&str>) -> String {
|
||||||
if let Some(url) = override_url {
|
if let Some(url) = override_url {
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,8 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
|
||||||
const tenant = await deps.repo.getTenantBySlug(tenantSlug);
|
const tenant = await deps.repo.getTenantBySlug(tenantSlug);
|
||||||
if (tenant && tenant.is_active) {
|
if (tenant && tenant.is_active) {
|
||||||
event.context.tenant = tenant;
|
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 {
|
} else {
|
||||||
// Fall back to default tenant.
|
|
||||||
const defaultTenant = await deps.repo.getTenantBySlug("default");
|
const defaultTenant = await deps.repo.getTenantBySlug("default");
|
||||||
if (defaultTenant) {
|
if (defaultTenant) {
|
||||||
event.context.tenant = defaultTenant;
|
event.context.tenant = defaultTenant;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { type H3, getRouterParam, readBody, createError } from "h3";
|
||||||
import { htmlPage } from "./html-response.js";
|
import { htmlPage } from "./html-response.js";
|
||||||
import type { AdminDeps } from "./index.js";
|
import type { AdminDeps } from "./index.js";
|
||||||
import * as ablesign from "../../shared/ablesign.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 {
|
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");
|
const encrypted = deps.secrets.encryptString(apiKey, "ablesign-key");
|
||||||
await deps.repo.createAbleSignAccount({ name, api_key_encrypted: encrypted, workspace_id: workspaceId });
|
const accountId = await deps.repo.createAbleSignAccount({ name, api_key_encrypted: encrypted, workspace_id: workspaceId });
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/ablesign" } });
|
|
||||||
|
// 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) => {
|
// Redirect old per-account route to global screens page.
|
||||||
const id = getRouterParam(event, "id") ?? "";
|
app.get("/admin/ablesign/:id/screens", async () => {
|
||||||
const account = await deps.repo.getAbleSignAccount(id);
|
return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
|
||||||
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 }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/ablesign/:id/sync", async (event) => {
|
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);
|
const result = await ablesign.listScreens(opts);
|
||||||
|
|
||||||
for (const s of result.data) {
|
for (const s of result.data) {
|
||||||
await deps.repo.upsertAbleSignScreen({
|
const screenRowId = await deps.repo.upsertAbleSignScreen({
|
||||||
account_id: id,
|
account_id: id,
|
||||||
ablesign_screen_id: String(s.id),
|
ablesign_screen_id: String(s.id),
|
||||||
title: s.title,
|
title: s.title,
|
||||||
|
|
@ -90,7 +107,7 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const body = await readBody<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const title = (body?.title ?? "").trim();
|
const title = (body?.title ?? "").trim();
|
||||||
if (!title) {
|
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 {
|
try {
|
||||||
|
|
@ -131,7 +148,7 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
|
||||||
// redirect back — error handling TODO
|
// redirect back — error handling TODO
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
app.post("/admin/ablesign/screens/:sid/assign", async (event) => {
|
||||||
|
|
@ -142,7 +159,55 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
const screen = await deps.repo.getAbleSignScreen(sid);
|
const screen = await deps.repo.getAbleSignScreen(sid);
|
||||||
const accountId = screen?.account_id ?? "";
|
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) => {
|
app.post("/admin/ablesign/:id/delete", async (event) => {
|
||||||
|
|
@ -168,6 +233,51 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
|
||||||
await deps.repo.deleteAbleSignScreen(sid);
|
await deps.repo.deleteAbleSignScreen(sid);
|
||||||
}
|
}
|
||||||
const accountId = screen?.account_id ?? "";
|
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 }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2219,6 +2219,21 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- 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 ---------------
|
// ---- JSON API (admin scope) — used by Node-RED bf-* nodes ---------------
|
||||||
//
|
//
|
||||||
// All payloads run through `stripSecrets` so credential-bearing fields
|
// All payloads run through `stripSecrets` so credential-bearing fields
|
||||||
|
|
|
||||||
|
|
@ -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[] = [];
|
const bundleCameras: BundleCamera[] = [];
|
||||||
for (const cam of cameras) {
|
for (const cam of cameras) {
|
||||||
const streams = await repo.listCameraStreams(cam.id);
|
const streams = await repo.listCameraStreams(cam.id);
|
||||||
|
|
|
||||||
|
|
@ -486,40 +486,6 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
`ALTER TABLE kiosks ADD COLUMN IF NOT EXISTS partitions_json JSONB`,
|
`ALTER TABLE kiosks ADD COLUMN IF NOT EXISTS partitions_json JSONB`,
|
||||||
|
|
||||||
// ---- AbleSign digital signage integration -----------------------------------
|
|
||||||
`CREATE TABLE IF NOT EXISTS ablesign_accounts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
api_key_encrypted TEXT NOT NULL,
|
|
||||||
workspace_id TEXT,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
screen_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_sync_at TIMESTAMPTZ,
|
|
||||||
last_sync_error TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE IF NOT EXISTS ablesign_screens (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
account_id TEXT NOT NULL REFERENCES ablesign_accounts(id) ON DELETE CASCADE,
|
|
||||||
ablesign_screen_id TEXT NOT NULL,
|
|
||||||
ablesign_screen_token_encrypted TEXT,
|
|
||||||
kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
orientation TEXT NOT NULL DEFAULT 'landscape',
|
|
||||||
online BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
last_heartbeat_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE(account_id, ablesign_screen_id)
|
|
||||||
)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_ablesign_screens_account ON ablesign_screens(account_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_ablesign_screens_kiosk ON ablesign_screens(kiosk_id)`,
|
|
||||||
|
|
||||||
// Add ablesign entity type + ablesign_screen_id column to entities
|
|
||||||
`ALTER TABLE entities DROP CONSTRAINT IF EXISTS entities_type_check`,
|
|
||||||
`ALTER TABLE entities ADD CONSTRAINT entities_type_check CHECK(type IN ('camera', 'html', 'web', 'dashboard', 'ablesign'))`,
|
|
||||||
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS ablesign_screen_id TEXT REFERENCES ablesign_screens(id) ON DELETE CASCADE`,
|
|
||||||
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false`,
|
|
||||||
|
|
||||||
// ---- UUIDv7 PK migration for existing databases ----
|
// ---- UUIDv7 PK migration for existing databases ----
|
||||||
// Databases created before UUIDv7 migration have INTEGER PKs.
|
// Databases created before UUIDv7 migration have INTEGER PKs.
|
||||||
// This migration converts them to TEXT in-place. Safe to run on
|
// This migration converts them to TEXT in-place. Safe to run on
|
||||||
|
|
@ -723,4 +689,36 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
|
|
||||||
RAISE NOTICE 'UUIDv7 backfill: all integer-looking IDs replaced with UUIDs';
|
RAISE NOTICE 'UUIDv7 backfill: all integer-looking IDs replaced with UUIDs';
|
||||||
END $$`,
|
END $$`,
|
||||||
|
|
||||||
|
// ---- AbleSign digital signage integration -----------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS ablesign_accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
api_key_encrypted TEXT NOT NULL,
|
||||||
|
workspace_id TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
screen_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_sync_at TIMESTAMPTZ,
|
||||||
|
last_sync_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS ablesign_screens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL REFERENCES ablesign_accounts(id) ON DELETE CASCADE,
|
||||||
|
ablesign_screen_id TEXT NOT NULL,
|
||||||
|
ablesign_screen_token_encrypted TEXT,
|
||||||
|
kiosk_id TEXT REFERENCES kiosks(id) ON DELETE SET NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
orientation TEXT NOT NULL DEFAULT 'landscape',
|
||||||
|
online BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
last_heartbeat_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(account_id, ablesign_screen_id)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_ablesign_screens_account ON ablesign_screens(account_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_ablesign_screens_kiosk ON ablesign_screens(kiosk_id)`,
|
||||||
|
`ALTER TABLE entities DROP CONSTRAINT IF EXISTS entities_type_check`,
|
||||||
|
`ALTER TABLE entities ADD CONSTRAINT entities_type_check CHECK(type IN ('camera', 'html', 'web', 'dashboard', 'ablesign'))`,
|
||||||
|
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS ablesign_screen_id TEXT REFERENCES ablesign_screens(id) ON DELETE CASCADE`,
|
||||||
|
`ALTER TABLE entities ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false`,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import type { DbAdapter, RunResult, Row, SqlValue } from "./db-adapter.js";
|
||||||
|
|
||||||
export class PgAdapter implements DbAdapter {
|
export class PgAdapter implements DbAdapter {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
/** Per-async-context client when inside transaction(). */
|
|
||||||
private currentTxClient: PoolClient | null = null;
|
private currentTxClient: PoolClient | null = null;
|
||||||
private txDepth = 0;
|
private txDepth = 0;
|
||||||
|
private searchPath = "public";
|
||||||
|
|
||||||
constructor(connectionString: string, poolMax: number = 10) {
|
constructor(connectionString: string, poolMax: number = 10) {
|
||||||
this.pool = new Pool({
|
this.pool = new Pool({
|
||||||
|
|
@ -70,8 +70,12 @@ export class PgAdapter implements DbAdapter {
|
||||||
private async runner<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> {
|
private async runner<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> {
|
||||||
if (this.currentTxClient) return fn(this.currentTxClient);
|
if (this.currentTxClient) return fn(this.currentTxClient);
|
||||||
const client = await this.pool.connect();
|
const client = await this.pool.connect();
|
||||||
try { return await fn(client); }
|
try {
|
||||||
finally { client.release(); }
|
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> {
|
async run(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<RunResult> {
|
||||||
|
|
@ -149,13 +153,10 @@ export class PgAdapter implements DbAdapter {
|
||||||
dialect(): "postgres" { return "postgres"; }
|
dialect(): "postgres" { return "postgres"; }
|
||||||
|
|
||||||
async setSearchPath(schema: string): Promise<void> {
|
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)) {
|
if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) {
|
||||||
throw new Error(`invalid schema name: ${schema}`);
|
throw new Error(`invalid schema name: ${schema}`);
|
||||||
}
|
}
|
||||||
await this.runner(async (c) => {
|
this.searchPath = schema;
|
||||||
await c.query(`SET search_path TO ${schema}, public`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,24 @@ export class Repository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async isSetupComplete(): Promise<boolean> {
|
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> {
|
async markSetupComplete(): Promise<void> {
|
||||||
|
|
@ -2402,6 +2419,14 @@ export class Repository {
|
||||||
* the camera's name is already taken by another entity, append the camera
|
* the camera's name is already taken by another entity, append the camera
|
||||||
* id to keep the name unique.
|
* 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> {
|
async ensureCameraEntity(camera: Camera): Promise<Entity> {
|
||||||
const existing = await this.getEntityForCamera(camera.id);
|
const existing = await this.getEntityForCamera(camera.id);
|
||||||
if (existing) return existing;
|
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 displays WHERE kiosk_id = ?`, [id]);
|
||||||
await this._run(`DELETE FROM kiosk_labels 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(`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]);
|
await this._run(`DELETE FROM kiosks WHERE id = ?`, [id]);
|
||||||
});
|
});
|
||||||
for (const display of displays) {
|
for (const display of displays) {
|
||||||
|
|
@ -2529,6 +2555,18 @@ export class Repository {
|
||||||
// camera_event_subscriptions
|
// 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[]> {
|
async listEventSubscriptions(cameraId: string): Promise<CameraEventSubscription[]> {
|
||||||
const rs = await this._all(
|
const rs = await this._all(
|
||||||
"SELECT * FROM camera_event_subscriptions WHERE camera_id = ? ORDER BY topic",
|
"SELECT * FROM camera_event_subscriptions WHERE camera_id = ? ORDER BY topic",
|
||||||
|
|
|
||||||
|
|
@ -4453,24 +4453,27 @@ export function AbleSignPage(props: AbleSignPageProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AbleSignScreensPageProps {
|
interface AbleSignScreensPageProps {
|
||||||
account: any;
|
|
||||||
screens: any[];
|
screens: any[];
|
||||||
kiosks: any[];
|
accountId: string | null;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
|
export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
|
||||||
const a = props.account;
|
const aid = props.accountId;
|
||||||
return (
|
return (
|
||||||
<Layout title={`AbleSign — ${String(a.name)}`} activeNav="ablesign">
|
<Layout title="AbleSign — Screens" activeNav="ablesign-screens">
|
||||||
<h1 style="font-size:1.5rem; margin:0 0 0.5rem">{a.name} — Screens</h1>
|
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign 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>
|
|
||||||
|
|
||||||
|
{props.error ? <div class="alert alert-error" style="margin-bottom:1rem">{props.error}</div> : ""}
|
||||||
|
|
||||||
|
{!aid ? (
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 style="font-size:1rem; margin:0 0 0.75rem">Add Screen</h2>
|
<p style="color:#999; font-size:0.85rem">No AbleSign account configured. Add one under Account settings first.</p>
|
||||||
<form method="POST" action={`/admin/ablesign/${String(a.id)}/screens/add`} style="display:flex; gap:0.5rem; align-items:end">
|
</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">
|
<label style="font-size:0.85rem">
|
||||||
{"Screen Name"}<br/>
|
{"Screen Name"}<br/>
|
||||||
<input type="text" name="title" required style="width:16rem" placeholder="Lobby Display" />
|
<input type="text" name="title" required style="width:16rem" placeholder="Lobby Display" />
|
||||||
|
|
@ -4478,20 +4481,23 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
|
||||||
<button type="submit" class="btn btn-sm">{"Create & Pair"}</button>
|
<button type="submit" class="btn btn-sm">{"Create & Pair"}</button>
|
||||||
</form>
|
</form>
|
||||||
<p style="font-size:0.8rem; color:#999; margin:0.5rem 0 0">
|
<p style="font-size:0.8rem; color:#999; margin:0.5rem 0 0">
|
||||||
Creates a new screen in AbleSign and pairs it automatically.
|
Registers a new screen in AbleSign headlessly and creates a linked entity for use in layouts.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:1rem">
|
<div class="card" style="margin-bottom:1rem">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem">
|
||||||
<h2 style="font-size:1rem; margin:0">Screens</h2>
|
<h2 style="font-size:1rem; margin:0">Screens</h2>
|
||||||
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`}>
|
{aid ? (
|
||||||
|
<form method="POST" action={`/admin/ablesign/${aid}/sync`}>
|
||||||
<button type="submit" class="btn btn-sm btn-ghost">Sync from AbleSign</button>
|
<button type="submit" class="btn btn-sm btn-ghost">Sync from AbleSign</button>
|
||||||
</form>
|
</form>
|
||||||
|
) : ""}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{props.screens.length === 0 ? (
|
{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">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
|
|
@ -4499,13 +4505,13 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Orientation</th>
|
<th>Orientation</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Assigned Kiosk</th>
|
<th>Source</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{props.screens.map((s: any) => (
|
{props.screens.map((s: any) => (
|
||||||
<tr>
|
<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 style="font-size:0.85rem">{s.orientation}</td>
|
||||||
<td>
|
<td>
|
||||||
{s.online
|
{s.online
|
||||||
|
|
@ -4513,16 +4519,9 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
|
||||||
: <span class="badge badge-gray">Offline</span>}
|
: <span class="badge badge-gray">Offline</span>}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/assign`}
|
{s.has_entity
|
||||||
style="display:flex; gap:0.25rem; align-items:center">
|
? <span class="badge badge-blue">Internal</span>
|
||||||
<select name="kiosk_id" style="font-size:0.85rem; max-width:14rem">
|
: <span class="badge badge-gray">External</span>}
|
||||||
<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>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/delete`} style="display:inline">
|
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/delete`} style="display:inline">
|
||||||
|
|
@ -4539,3 +4538,117 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
|
||||||
</Layout>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
function Sidebar(props: { activeNav?: string }) {
|
||||||
const a = props.activeNav;
|
const a = props.activeNav;
|
||||||
return (
|
return (
|
||||||
|
|
@ -58,7 +72,11 @@ function Sidebar(props: { activeNav?: string }) {
|
||||||
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
||||||
<NavItem href="/admin/os-updates" label="OS Updates" icon="●" active={a === "os-updates"} />
|
<NavItem href="/admin/os-updates" label="OS Updates" icon="●" active={a === "os-updates"} />
|
||||||
<NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="☁" active={a === "cloud"} />
|
<NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="☁" active={a === "cloud"} />
|
||||||
<NavItem href="/admin/ablesign" label="AbleSign" icon="▶" active={a === "ablesign"} />
|
<NavGroup label="AbleSign" icon="▶" 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="◆" active={a === "labels"} />
|
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||||
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
||||||
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
||||||
|
|
@ -66,6 +84,7 @@ function Sidebar(props: { activeNav?: string }) {
|
||||||
<hr />
|
<hr />
|
||||||
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
||||||
<NavItem href="/admin/nodered" label="Node-RED" icon="→" active={a === "nodered"} />
|
<NavItem href="/admin/nodered" label="Node-RED" icon="→" active={a === "nodered"} />
|
||||||
|
<div class="tenant-switcher" {...{"hx-get": "/admin/_tenant_switcher", "hx-trigger": "load", "hx-swap": "innerHTML"}}></div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
@ -200,6 +219,12 @@ const baseStyles = {
|
||||||
".nav-item:hover": { backgroundColor: "#2a2a4e", color: "#fff", textDecoration: "none" },
|
".nav-item:hover": { backgroundColor: "#2a2a4e", color: "#fff", textDecoration: "none" },
|
||||||
".nav-item.active": { backgroundColor: "#2563eb", color: "#fff" },
|
".nav-item.active": { backgroundColor: "#2563eb", color: "#fff" },
|
||||||
".nav-icon": { fontSize: "0.75rem", width: "1.25rem", textAlign: "center" as const },
|
".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" },
|
".sidebar hr": { border: "none", borderTop: "1px solid #2a2a4e", margin: "0.5rem 0" },
|
||||||
".topbar": {
|
".topbar": {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue