fix: make timeline activation durable and visible

This commit is contained in:
ekklesia
2026-09-29 18:15:08 -05:00
parent 92bef4c7aa
commit 5760059ee3
5 changed files with 156 additions and 11 deletions
+17 -8
View File
@@ -62,6 +62,10 @@ export function SignageDashboard({
const selectedGroupId = const selectedGroupId =
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined); 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 ( return (
<> <>
@@ -93,18 +97,23 @@ export function SignageDashboard({
{ {
content: ( content: (
<TimelinesPanel <TimelinesPanel
{...(selectedGroup
? {
activeGroupName: selectedGroup.name,
activeTimelineId: selectedGroup.playback.activeTimelineId,
}
: {})}
api={api} api={api}
canManage={mayManageTimelines} canManage={mayManageTimelines}
onActivate={ onActivate={
mayControl && selectedGroupId !== undefined mayControl && selectedGroupId !== undefined
? (timelineId) => { ? async (timelineId) => {
void api await api.send("POST", `playback/${selectedGroupId}/activate`, {
.send("POST", `playback/${selectedGroupId}/activate`, { timelineId,
timelineId, when: "now",
when: "now", });
}) groups.reload();
.then(() => setTab("playback")) setTab("playback");
.catch(() => setTab("playback"));
} }
: undefined : undefined
} }
+15
View File
@@ -70,6 +70,16 @@ export function PlaybackPanel({
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body)); 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 ( return (
<div className="signage-columns"> <div className="signage-columns">
<Panel <Panel
@@ -108,6 +118,11 @@ export function PlaybackPanel({
) : ( ) : (
<div className="signage-playback" data-testid="signage-playback"> <div className="signage-playback" data-testid="signage-playback">
<div className="signage-playback__status"> <div className="signage-playback__status">
{value.snapshot.activeTimelineId === null ? undefined : (
<Badge tone="success">
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
</Badge>
)}
<Badge <Badge
tone={ tone={
value.snapshot.transport === "playing" value.snapshot.transport === "playing"
+18 -3
View File
@@ -42,11 +42,15 @@ export function TimelinesPanel({
settings, settings,
canManage, canManage,
onActivate, onActivate,
activeTimelineId,
activeGroupName,
}: { }: {
readonly api: SignageApi; readonly api: SignageApi;
readonly settings: SettingsResponse; readonly settings: SettingsResponse;
readonly canManage: boolean; readonly canManage: boolean;
readonly onActivate: ((timelineId: string) => void) | undefined; readonly onActivate: ((timelineId: string) => Promise<void>) | undefined;
readonly activeTimelineId?: string | null;
readonly activeGroupName?: string;
}): ReactElement { }): ReactElement {
const timelines = useResource( const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
@@ -62,6 +66,7 @@ export function TimelinesPanel({
setNewName(""); setNewName("");
timelines.reload(); timelines.reload();
}); });
const activation = useAction();
return ( return (
<div className="signage-columns"> <div className="signage-columns">
@@ -117,6 +122,11 @@ export function TimelinesPanel({
<TableRow key={timeline.id} selected={timeline.id === selectedId}> <TableRow key={timeline.id} selected={timeline.id === selectedId}>
<TableCell> <TableCell>
<Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button> <Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button>
{timeline.id === activeTimelineId ? (
<Badge tone="success">
{activeGroupName ? `Active in ${activeGroupName}` : "Active"}
</Badge>
) : undefined}
</TableCell> </TableCell>
<TableCell> <TableCell>
{timeline.eligibleFrom === null && timeline.eligibleUntil === null {timeline.eligibleFrom === null && timeline.eligibleUntil === null
@@ -126,8 +136,12 @@ export function TimelinesPanel({
<TableCell>{timeline.revision}</TableCell> <TableCell>{timeline.revision}</TableCell>
<TableCell> <TableCell>
{onActivate === undefined ? undefined : ( {onActivate === undefined ? undefined : (
<Button onClick={() => onActivate(timeline.id)} variant="primary"> <Button
Activate disabled={activation.pending || timeline.id === activeTimelineId}
onClick={() => activation.run(() => onActivate(timeline.id))}
variant="primary"
>
{timeline.id === activeTimelineId ? "Active" : "Activate"}
</Button> </Button>
)} )}
</TableCell> </TableCell>
@@ -139,6 +153,7 @@ export function TimelinesPanel({
{ label: "Loading timelines…", onRetry: timelines.reload }, { label: "Loading timelines…", onRetry: timelines.reload },
)} )}
<FailureNote failure={create.failure} /> <FailureNote failure={create.failure} />
<FailureNote failure={activation.failure} />
</Panel> </Panel>
{selectedId === undefined ? undefined : ( {selectedId === undefined ? undefined : (
+82
View File
@@ -120,6 +120,16 @@ const PLAYBACK_DETAIL = {
snapshot: GROUP.playback, 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(() => { beforeEach(() => {
container = document.createElement("div"); container = document.createElement("div");
document.body.append(container); document.body.append(container);
@@ -194,6 +204,78 @@ describe("Signage dashboard", () => {
expect(container.querySelector("[data-testid='signage-controls']")).toBeNull(); 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(<SignageDashboard context={host.context} />);
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(<SignageDashboard context={host.context} />);
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 () => { it("shows Signage's own sanitized reason when a save is refused", async () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"], permissions: ["signage.view", "signage.timelines.manage"],
+24
View File
@@ -589,6 +589,30 @@ describe("authoritative playback", () => {
expect(announcements.resolveRequests.length).toBe(callsBefore); 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 () => { it("publishes a compact snapshot, with no payload or timeline in it", async () => {
const group = await seedGroup(); const group = await seedGroup();
const timeline = await seedPattern(); const timeline = await seedPattern();