Fix/signage stabilization #1
@@ -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",
|
||||||
})
|
});
|
||||||
.then(() => setTab("playback"))
|
groups.reload();
|
||||||
.catch(() => setTab("playback"));
|
setTab("playback");
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 : (
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user