From 5760059ee361c88c3625effd2ce2653d30c3fe16 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Tue, 29 Sep 2026 18:15:08 -0500 Subject: [PATCH 1/2] fix: make timeline activation durable and visible --- src/ui/SignageDashboard.tsx | 25 ++++++--- src/ui/panels/PlaybackPanel.tsx | 15 +++++ src/ui/panels/TimelinesPanel.tsx | 21 ++++++- tests/browser/management-ui.test.tsx | 82 ++++++++++++++++++++++++++++ tests/playback.test.ts | 24 ++++++++ 5 files changed, 156 insertions(+), 11 deletions(-) diff --git a/src/ui/SignageDashboard.tsx b/src/ui/SignageDashboard.tsx index 216e815..0267b86 100644 --- a/src/ui/SignageDashboard.tsx +++ b/src/ui/SignageDashboard.tsx @@ -62,6 +62,10 @@ export function SignageDashboard({ const selectedGroupId = groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined); + const selectedGroup = + groups.state.status === "ready" + ? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId) + : undefined; return ( <> @@ -93,18 +97,23 @@ export function SignageDashboard({ { content: ( { - void api - .send("POST", `playback/${selectedGroupId}/activate`, { - timelineId, - when: "now", - }) - .then(() => setTab("playback")) - .catch(() => setTab("playback")); + ? async (timelineId) => { + await api.send("POST", `playback/${selectedGroupId}/activate`, { + timelineId, + when: "now", + }); + groups.reload(); + setTab("playback"); } : undefined } diff --git a/src/ui/panels/PlaybackPanel.tsx b/src/ui/panels/PlaybackPanel.tsx index 4bf1f5f..2024068 100644 --- a/src/ui/panels/PlaybackPanel.tsx +++ b/src/ui/panels/PlaybackPanel.tsx @@ -70,6 +70,16 @@ export function PlaybackPanel({ command.run(() => api.send("POST", `playback/${groupId}/${path}`, body)); } + function timelineLabel(timelineId: string): string { + if (timelines.state.status !== "ready") { + return timelineId; + } + return ( + timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ?? + timelineId + ); + } + return (
+ {value.snapshot.activeTimelineId === null ? undefined : ( + + {`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`} + + )} void) | undefined; + readonly onActivate: ((timelineId: string) => Promise) | undefined; + readonly activeTimelineId?: string | null; + readonly activeGroupName?: string; }): ReactElement { const timelines = useResource( () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), @@ -62,6 +66,7 @@ export function TimelinesPanel({ setNewName(""); timelines.reload(); }); + const activation = useAction(); return (
@@ -117,6 +122,11 @@ export function TimelinesPanel({ + {timeline.id === activeTimelineId ? ( + + {activeGroupName ? `Active in ${activeGroupName}` : "Active"} + + ) : undefined} {timeline.eligibleFrom === null && timeline.eligibleUntil === null @@ -126,8 +136,12 @@ export function TimelinesPanel({ {timeline.revision} {onActivate === undefined ? undefined : ( - )} @@ -139,6 +153,7 @@ export function TimelinesPanel({ { label: "Loading timelines…", onRetry: timelines.reload }, )} + {selectedId === undefined ? undefined : ( diff --git a/tests/browser/management-ui.test.tsx b/tests/browser/management-ui.test.tsx index 40eaae0..06efbb3 100644 --- a/tests/browser/management-ui.test.tsx +++ b/tests/browser/management-ui.test.tsx @@ -120,6 +120,16 @@ const PLAYBACK_DETAIL = { snapshot: GROUP.playback, }; +const TIMELINE = { + description: null, + eligibleFrom: null, + eligibleUntil: null, + id: "timeline-1", + name: "Sunday morning", + revision: 3, + updatedAt: "2026-08-30T15:00:00.000Z", +}; + beforeEach(() => { container = document.createElement("div"); document.body.append(container); @@ -194,6 +204,78 @@ describe("Signage dashboard", () => { expect(container.querySelector("[data-testid='signage-controls']")).toBeNull(); }); + it("shows which timeline remains active after the dashboard data is reloaded", async () => { + const host = createFakeHost({ + permissions: ["signage.view", "signage.playback.control"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [TIMELINE] }), + }, + { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, + { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, + ], + }); + + await render(); + expect(container.textContent).toContain("Active: Sunday morning"); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (candidate) => candidate.textContent === "Timelines", + ), + ); + + expect(container.textContent).toContain("Active in Main"); + expect(buttonNamed("Active")?.disabled).toBe(true); + }); + + it("keeps a failed activation visible instead of pretending it succeeded", async () => { + const inactiveGroup = { + ...GROUP, + playback: { ...GROUP.playback, activeTimelineId: null, currentSelectionId: null }, + }; + const host = createFakeHost({ + permissions: ["signage.view", "signage.playback.control"], + routes: [ + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [inactiveGroup] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [TIMELINE] }), + }, + { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, + { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, + { + method: "POST", + path: "playback/group-1/activate", + respond: () => + errorResponse("SIGNAGE_CONFLICT", "The request conflicts with the current state.", 409), + }, + ], + }); + + await render(); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (candidate) => candidate.textContent === "Timelines", + ), + ); + await click(buttonNamed("Activate")); + + expect(container.textContent).toContain("Sunday morning"); + expect(container.querySelector("[role='alert']")?.textContent).toContain( + "The request conflicts with the current state.", + ); + }); + it("shows Signage's own sanitized reason when a save is refused", async () => { const host = createFakeHost({ permissions: ["signage.view", "signage.timelines.manage"], diff --git a/tests/playback.test.ts b/tests/playback.test.ts index ab9fda5..9afcaa0 100644 --- a/tests/playback.test.ts +++ b/tests/playback.test.ts @@ -589,6 +589,30 @@ describe("authoritative playback", () => { expect(announcements.resolveRequests.length).toBe(callsBefore); }); + it("keeps the active timeline through a complete playback-service restart", async () => { + const group = await seedGroup(); + const timeline = await seedPattern(); + await service.activate(group.group.id, timeline.timeline.id, "now"); + const before = await service.snapshot(group.group.id); + + service.stop(); + clock += 2000; + service = createPlaybackService({ + newId, + now: () => clock, + registry, + setTimer: () => undefined, + settings: { ...SIGNAGE_DEFAULT_SETTINGS, playbackHistoryLimit: 20 }, + store, + }); + await service.start(); + + const after = await service.snapshot(group.group.id); + expect(after.activeTimelineId).toBe(timeline.timeline.id); + expect(after.currentSelectionId).toBe(before.currentSelectionId); + expect(after.deadlineAtMs).toBe(before.deadlineAtMs); + }); + it("publishes a compact snapshot, with no payload or timeline in it", async () => { const group = await seedGroup(); const timeline = await seedPattern(); From aa8cb1c96f2577676e9adb77d7553955accebd2e Mon Sep 17 00:00:00 2001 From: ekklesia Date: Tue, 29 Sep 2026 18:15:26 -0500 Subject: [PATCH 2/2] fix: complete display and channel group management --- src/server/routes/playback.ts | 11 +++- src/server/store/drizzle-playback.ts | 18 +++++- src/server/store/types.ts | 4 +- src/ui/SignageDashboard.tsx | 11 +++- src/ui/panels/DisplaysPanel.tsx | 90 ++++++++++++++++++++++++---- src/ui/styles.css | 34 +++++++++++ tests/browser/management-ui.test.tsx | 50 ++++++++++++++++ tests/support/memory-playback.ts | 2 +- tests/support/memory-store.ts | 14 +++++ tests/viewers.test.ts | 28 +++++++++ 10 files changed, 243 insertions(+), 19 deletions(-) diff --git a/src/server/routes/playback.ts b/src/server/routes/playback.ts index 5fcd252..e90a81a 100644 --- a/src/server/routes/playback.ts +++ b/src/server/routes/playback.ts @@ -91,11 +91,20 @@ export function registerPlaybackRoutes( const groupId = requireUuidParam(context, "groupId"); const store = createSignageStore(context.services.database); const groups = await store.listChannelGroups(); + const group = groups.find((candidate) => candidate.group.id === groupId); + if (!group) { + throw new SignageError("SIGNAGE_NOT_FOUND"); + } if (groups.length <= 1) { // An installation always has somewhere for a viewer to land. throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" }); } - await store.deleteChannelGroup(groupId); + const fallbackGroup = groups.find((candidate) => candidate.group.id !== groupId); + const fallbackChannel = fallbackGroup?.channels.find((channel) => channel.isDefault); + if (!fallbackChannel) { + throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-fallback-channel" }); + } + await store.deleteChannelGroup(groupId, fallbackChannel.id); return new Response(null, { status: 204 }); }); }, diff --git a/src/server/store/drizzle-playback.ts b/src/server/store/drizzle-playback.ts index 7995e31..d00ccc1 100644 --- a/src/server/store/drizzle-playback.ts +++ b/src/server/store/drizzle-playback.ts @@ -7,6 +7,7 @@ import { playbackStates, selections, selectionVariants, + viewers, } from "../../schema/tables.js"; import { type NewStoredSelection, @@ -176,8 +177,21 @@ export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackSt }); }, - async deleteChannelGroup(groupId: string): Promise { - await database.delete(channelGroups).where(eq(channelGroups.id, groupId)); + async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise { + await database.transaction(async (transaction) => { + const removedChannels = await transaction + .select({ id: channels.id }) + .from(channels) + .where(eq(channels.groupId, groupId)); + const removedChannelIds = removedChannels.map((channel) => channel.id); + if (removedChannelIds.length > 0) { + await transaction + .update(viewers) + .set({ channelId: fallbackChannelId, updatedAt: new Date() }) + .where(inArray(viewers.channelId, removedChannelIds)); + } + await transaction.delete(channelGroups).where(eq(channelGroups.id, groupId)); + }); }, async findChannelGroup(groupId: string): Promise { diff --git a/src/server/store/types.ts b/src/server/store/types.ts index abfd423..c80079b 100644 --- a/src/server/store/types.ts +++ b/src/server/store/types.ts @@ -351,7 +351,9 @@ export interface SignagePlaybackStore { readonly channelId: string; readonly channelName: string; }): Promise; - deleteChannelGroup(groupId: string): Promise; + /** Deletes a group and moves every assigned viewer to the supplied fallback channel atomically, + * so removing a group can never leave a display unassigned. */ + deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise; addChannel(channel: StoredChannel): Promise; /** Refuses to remove a group's default channel: something must remain for a viewer to join. */ removeChannel(channelId: string): Promise; diff --git a/src/ui/SignageDashboard.tsx b/src/ui/SignageDashboard.tsx index 0267b86..8319e80 100644 --- a/src/ui/SignageDashboard.tsx +++ b/src/ui/SignageDashboard.tsx @@ -131,7 +131,16 @@ export function SignageDashboard({ label: "Images", }, { - content: , + content: ( + { + setGroupId(undefined); + groups.reload(); + }} + /> + ), id: "displays", label: "Displays", }, diff --git a/src/ui/panels/DisplaysPanel.tsx b/src/ui/panels/DisplaysPanel.tsx index 241d77a..8facd36 100644 --- a/src/ui/panels/DisplaysPanel.tsx +++ b/src/ui/panels/DisplaysPanel.tsx @@ -18,6 +18,8 @@ import { useAction, useResource, useTick } from "../hooks.js"; import type { ChannelGroupView, ViewerHealthView } from "../types.js"; import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js"; +const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; + /** * Channel groups, audience channels, and the display roster. * @@ -28,9 +30,11 @@ import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared. export function DisplaysPanel({ api, canManage, + onGroupsChanged, }: { readonly api: SignageApi; readonly canManage: boolean; + readonly onGroupsChanged?: () => void; }): ReactElement { const groups = useResource( () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), @@ -43,8 +47,11 @@ export function DisplaysPanel({ const edit = useAction(() => { groups.reload(); viewers.reload(); + onGroupsChanged?.(); }); + const viewerEdit = useAction(viewers.reload); const [groupName, setGroupName] = useState(""); + const [confirmGroupId, setConfirmGroupId] = useState(undefined); const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({ groupId: "", key: "", @@ -105,7 +112,41 @@ export function DisplaysPanel({

{group.name}

{group.playback.transport} + {canManage && value.groups.length > 1 ? ( + + ) : canManage ? ( + At least one group required + ) : undefined}
+ {confirmGroupId === group.id ? ( +
+

+ Delete {group.name}? Its playback history will be removed. Displays assigned + here will move to the default channel of another group. +

+ + +
+ ) : undefined}
    {group.channels.map((channel) => (
  • @@ -129,32 +170,43 @@ export function DisplaysPanel({
{canManage ? (
- + {(control) => ( - setChannelDraft({ + setChannelDraft((current) => ({ groupId: group.id, key: event.target.value, - name: channelDraft.name, - }) + name: current.groupId === group.id ? current.name : "", + })) } + pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*" + placeholder="online" value={channelDraft.groupId === group.id ? channelDraft.key : ""} /> )} - + {(control) => ( - setChannelDraft({ + setChannelDraft((current) => ({ groupId: group.id, - key: channelDraft.key, + key: current.groupId === group.id ? current.key : "", name: event.target.value, - }) + })) } + placeholder="Online audience" value={channelDraft.groupId === group.id ? channelDraft.name : ""} /> )} @@ -163,7 +215,8 @@ export function DisplaysPanel({ disabled={ edit.pending || channelDraft.groupId !== group.id || - channelDraft.key.trim() === "" || + !AUDIENCE_KEY.test(channelDraft.key.trim()) || + channelDraft.key.trim() === "default" || channelDraft.name.trim() === "" } onClick={() => @@ -189,15 +242,25 @@ export function DisplaysPanel({ + {canManage ? ( +
+

Register a display

+
    +
  1. Open the Ekklesia path /display/signage on the screen.
  2. +
  3. Enter its six-digit code under Display devices in the Dashboard sidebar.
  4. +
  5. When it appears below, give it a clear name and assign its channel.
  6. +
+
+ ) : undefined} {renderLoadable( viewers.state, (value) => value.viewers.length === 0 ? ( ) : ( @@ -222,7 +285,7 @@ export function DisplaysPanel({ defaultValue={viewer.name} onBlur={(event) => event.target.value !== viewer.name && - edit.run(() => + viewerEdit.run(() => api.send("PATCH", `viewers/${viewer.id}`, { name: event.target.value, }), @@ -241,7 +304,7 @@ export function DisplaysPanel({