Merge pull request 'Fix/signage stabilization' (#1) from fix/signage-stabilization into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
@@ -91,11 +91,20 @@ export function registerPlaybackRoutes(
|
|||||||
const groupId = requireUuidParam(context, "groupId");
|
const groupId = requireUuidParam(context, "groupId");
|
||||||
const store = createSignageStore(context.services.database);
|
const store = createSignageStore(context.services.database);
|
||||||
const groups = await store.listChannelGroups();
|
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) {
|
if (groups.length <= 1) {
|
||||||
// An installation always has somewhere for a viewer to land.
|
// An installation always has somewhere for a viewer to land.
|
||||||
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
|
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 });
|
return new Response(null, { status: 204 });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
playbackStates,
|
playbackStates,
|
||||||
selections,
|
selections,
|
||||||
selectionVariants,
|
selectionVariants,
|
||||||
|
viewers,
|
||||||
} from "../../schema/tables.js";
|
} from "../../schema/tables.js";
|
||||||
import {
|
import {
|
||||||
type NewStoredSelection,
|
type NewStoredSelection,
|
||||||
@@ -176,8 +177,21 @@ export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackSt
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteChannelGroup(groupId: string): Promise<void> {
|
async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void> {
|
||||||
await database.delete(channelGroups).where(eq(channelGroups.id, groupId));
|
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<StoredChannelGroupDetail | undefined> {
|
async findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
|
||||||
|
|||||||
@@ -351,7 +351,9 @@ export interface SignagePlaybackStore {
|
|||||||
readonly channelId: string;
|
readonly channelId: string;
|
||||||
readonly channelName: string;
|
readonly channelName: string;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
deleteChannelGroup(groupId: string): Promise<void>;
|
/** 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<void>;
|
||||||
addChannel(channel: StoredChannel): Promise<void>;
|
addChannel(channel: StoredChannel): Promise<void>;
|
||||||
/** Refuses to remove a group's default channel: something must remain for a viewer to join. */
|
/** Refuses to remove a group's default channel: something must remain for a viewer to join. */
|
||||||
removeChannel(channelId: string): Promise<void>;
|
removeChannel(channelId: string): Promise<void>;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -122,7 +131,16 @@ export function SignageDashboard({
|
|||||||
label: "Images",
|
label: "Images",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: <DisplaysPanel api={api} canManage={mayManageDisplays} />,
|
content: (
|
||||||
|
<DisplaysPanel
|
||||||
|
api={api}
|
||||||
|
canManage={mayManageDisplays}
|
||||||
|
onGroupsChanged={() => {
|
||||||
|
setGroupId(undefined);
|
||||||
|
groups.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
id: "displays",
|
id: "displays",
|
||||||
label: "Displays",
|
label: "Displays",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { useAction, useResource, useTick } from "../hooks.js";
|
|||||||
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
|
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
|
||||||
import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.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.
|
* Channel groups, audience channels, and the display roster.
|
||||||
*
|
*
|
||||||
@@ -28,9 +30,11 @@ import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.
|
|||||||
export function DisplaysPanel({
|
export function DisplaysPanel({
|
||||||
api,
|
api,
|
||||||
canManage,
|
canManage,
|
||||||
|
onGroupsChanged,
|
||||||
}: {
|
}: {
|
||||||
readonly api: SignageApi;
|
readonly api: SignageApi;
|
||||||
readonly canManage: boolean;
|
readonly canManage: boolean;
|
||||||
|
readonly onGroupsChanged?: () => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const groups = useResource(
|
const groups = useResource(
|
||||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||||
@@ -43,8 +47,11 @@ export function DisplaysPanel({
|
|||||||
const edit = useAction(() => {
|
const edit = useAction(() => {
|
||||||
groups.reload();
|
groups.reload();
|
||||||
viewers.reload();
|
viewers.reload();
|
||||||
|
onGroupsChanged?.();
|
||||||
});
|
});
|
||||||
|
const viewerEdit = useAction(viewers.reload);
|
||||||
const [groupName, setGroupName] = useState("");
|
const [groupName, setGroupName] = useState("");
|
||||||
|
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined);
|
||||||
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
|
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
|
||||||
groupId: "",
|
groupId: "",
|
||||||
key: "",
|
key: "",
|
||||||
@@ -105,7 +112,41 @@ export function DisplaysPanel({
|
|||||||
<div className="signage-group__header">
|
<div className="signage-group__header">
|
||||||
<h4 className="signage-editor__subtitle">{group.name}</h4>
|
<h4 className="signage-editor__subtitle">{group.name}</h4>
|
||||||
<Badge tone="info">{group.playback.transport}</Badge>
|
<Badge tone="info">{group.playback.transport}</Badge>
|
||||||
|
{canManage && value.groups.length > 1 ? (
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending}
|
||||||
|
onClick={() => setConfirmGroupId(group.id)}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
Delete group
|
||||||
|
</Button>
|
||||||
|
) : canManage ? (
|
||||||
|
<Badge tone="neutral">At least one group required</Badge>
|
||||||
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
|
{confirmGroupId === group.id ? (
|
||||||
|
<div className="signage-confirm" role="alert">
|
||||||
|
<p>
|
||||||
|
Delete {group.name}? Its playback history will be removed. Displays assigned
|
||||||
|
here will move to the default channel of another group.
|
||||||
|
</p>
|
||||||
|
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(async () => {
|
||||||
|
await api.send("DELETE", `channel-groups/${group.id}`);
|
||||||
|
setConfirmGroupId(undefined);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
Confirm delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
<ul className="ek-list">
|
<ul className="ek-list">
|
||||||
{group.channels.map((channel) => (
|
{group.channels.map((channel) => (
|
||||||
<li className="ek-list__item signage-row" key={channel.id}>
|
<li className="ek-list__item signage-row" key={channel.id}>
|
||||||
@@ -129,32 +170,43 @@ export function DisplaysPanel({
|
|||||||
</ul>
|
</ul>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<div className="signage-inline-form" data-testid="signage-add-channel">
|
<div className="signage-inline-form" data-testid="signage-add-channel">
|
||||||
<Field hint="Lowercase, e.g. online" label="Audience key">
|
<Field
|
||||||
|
hint="Stable lowercase ID sent to content providers for audience-specific variants, e.g. online."
|
||||||
|
label="Provider audience key"
|
||||||
|
>
|
||||||
{(control) => (
|
{(control) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
{...control}
|
{...control}
|
||||||
|
maxLength={64}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setChannelDraft({
|
setChannelDraft((current) => ({
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
key: event.target.value,
|
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 : ""}
|
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Channel name">
|
<Field
|
||||||
|
hint="Friendly label shown to administrators and used when assigning displays."
|
||||||
|
label="Dashboard channel name"
|
||||||
|
>
|
||||||
{(control) => (
|
{(control) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
{...control}
|
{...control}
|
||||||
|
maxLength={120}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setChannelDraft({
|
setChannelDraft((current) => ({
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
key: channelDraft.key,
|
key: current.groupId === group.id ? current.key : "",
|
||||||
name: event.target.value,
|
name: event.target.value,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
placeholder="Online audience"
|
||||||
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -163,7 +215,8 @@ export function DisplaysPanel({
|
|||||||
disabled={
|
disabled={
|
||||||
edit.pending ||
|
edit.pending ||
|
||||||
channelDraft.groupId !== group.id ||
|
channelDraft.groupId !== group.id ||
|
||||||
channelDraft.key.trim() === "" ||
|
!AUDIENCE_KEY.test(channelDraft.key.trim()) ||
|
||||||
|
channelDraft.key.trim() === "default" ||
|
||||||
channelDraft.name.trim() === ""
|
channelDraft.name.trim() === ""
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -189,15 +242,25 @@ export function DisplaysPanel({
|
|||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel
|
<Panel
|
||||||
description="Every screen Signage has seen, and what it last reported."
|
description="Pair a screen through Ekklesia, then name it and choose which audience channel it shows."
|
||||||
title="Displays"
|
title="Displays"
|
||||||
>
|
>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="signage-registration-guide">
|
||||||
|
<h4 className="signage-editor__subtitle">Register a display</h4>
|
||||||
|
<ol>
|
||||||
|
<li>Open the Ekklesia path /display/signage on the screen.</li>
|
||||||
|
<li>Enter its six-digit code under Display devices in the Dashboard sidebar.</li>
|
||||||
|
<li>When it appears below, give it a clear name and assign its channel.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
{renderLoadable(
|
{renderLoadable(
|
||||||
viewers.state,
|
viewers.state,
|
||||||
(value) =>
|
(value) =>
|
||||||
value.viewers.length === 0 ? (
|
value.viewers.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
description="Open a Signage display on a screen and it will register itself here."
|
description="Open /display/signage on the screen, approve its code under Display devices, and it will appear here automatically."
|
||||||
title="No displays yet"
|
title="No displays yet"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -222,7 +285,7 @@ export function DisplaysPanel({
|
|||||||
defaultValue={viewer.name}
|
defaultValue={viewer.name}
|
||||||
onBlur={(event) =>
|
onBlur={(event) =>
|
||||||
event.target.value !== viewer.name &&
|
event.target.value !== viewer.name &&
|
||||||
edit.run(() =>
|
viewerEdit.run(() =>
|
||||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
api.send("PATCH", `viewers/${viewer.id}`, {
|
||||||
name: event.target.value,
|
name: event.target.value,
|
||||||
}),
|
}),
|
||||||
@@ -241,7 +304,7 @@ export function DisplaysPanel({
|
|||||||
<Select
|
<Select
|
||||||
aria-label={`Channel for ${viewer.name}`}
|
aria-label={`Channel for ${viewer.name}`}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
edit.run(() =>
|
viewerEdit.run(() =>
|
||||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
api.send("PATCH", `viewers/${viewer.id}`, {
|
||||||
channelId: event.target.value,
|
channelId: event.target.value,
|
||||||
}),
|
}),
|
||||||
@@ -282,6 +345,7 @@ export function DisplaysPanel({
|
|||||||
{ label: "Loading displays…", onRetry: viewers.reload },
|
{ label: "Loading displays…", onRetry: viewers.reload },
|
||||||
)}
|
)}
|
||||||
<FailureNote failure={edit.failure} />
|
<FailureNote failure={edit.failure} />
|
||||||
|
<FailureNote failure={viewerEdit.failure} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 : (
|
||||||
|
|||||||
@@ -349,6 +349,40 @@
|
|||||||
gap: var(--signage-gap-tight);
|
gap: var(--signage-gap-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.signage-group__header .ek-button:first-of-type {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-confirm {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--ekklesia-status-danger-background);
|
||||||
|
border-radius: var(--ekklesia-radius-md);
|
||||||
|
color: var(--ekklesia-text-danger);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
padding: var(--ekklesia-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-confirm p {
|
||||||
|
flex: 1 1 30rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-registration-guide {
|
||||||
|
background: var(--ekklesia-background-inset);
|
||||||
|
border-radius: var(--ekklesia-radius-md);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ekklesia-spacing-xs);
|
||||||
|
margin-block-end: var(--signage-gap);
|
||||||
|
padding: var(--ekklesia-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-registration-guide ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-inline-start: var(--ekklesia-spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
.signage-provider__id {
|
.signage-provider__id {
|
||||||
font-weight: var(--ekklesia-typography-weight-medium);
|
font-weight: var(--ekklesia-typography-weight-medium);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
@@ -405,6 +487,56 @@ describe("Signage dashboard", () => {
|
|||||||
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
|
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("explains display registration and deletes a channel group only after confirmation", async () => {
|
||||||
|
const secondGroup = {
|
||||||
|
...GROUP,
|
||||||
|
channels: [{ id: "chan-2", isDefault: true, key: "default", name: "Default" }],
|
||||||
|
id: "group-2",
|
||||||
|
name: "Foyer",
|
||||||
|
playback: { ...GROUP.playback, groupId: "group-2" },
|
||||||
|
};
|
||||||
|
let currentGroups = [GROUP, secondGroup];
|
||||||
|
const deleted: string[] = [];
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view", "signage.displays.manage"],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "channel-groups",
|
||||||
|
respond: () => jsonResponse({ groups: currentGroups }),
|
||||||
|
},
|
||||||
|
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
|
||||||
|
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
|
||||||
|
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) },
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: "channel-groups/group-1",
|
||||||
|
respond: ({ path }) => {
|
||||||
|
deleted.push(path);
|
||||||
|
currentGroups = [secondGroup];
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(<SignageDashboard context={host.context} />);
|
||||||
|
await click(
|
||||||
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
|
(candidate) => candidate.textContent === "Displays",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("/display/signage");
|
||||||
|
expect(container.textContent).toContain("Provider audience key");
|
||||||
|
await click(buttonNamed("Delete group"));
|
||||||
|
expect(deleted).toEqual([]);
|
||||||
|
expect(container.textContent).toContain("Displays assigned here will move");
|
||||||
|
await click(buttonNamed("Confirm delete"));
|
||||||
|
expect(deleted).toEqual(["channel-groups/group-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("uploads one request per file and reports each one's own outcome", async () => {
|
it("uploads one request per file and reports each one's own outcome", async () => {
|
||||||
const uploaded: string[] = [];
|
const uploaded: string[] = [];
|
||||||
const host = createFakeHost({
|
const host = createFakeHost({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function createMemoryPlaybackStore(
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteChannelGroup(groupId: string): Promise<void> {
|
deleteChannelGroup(groupId: string, _fallbackChannelId: string): Promise<void> {
|
||||||
groups.delete(groupId);
|
groups.delete(groupId);
|
||||||
playback.delete(groupId);
|
playback.delete(groupId);
|
||||||
for (const [id, channel] of channels) {
|
for (const [id, channel] of channels) {
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ export function createMemorySignageStore(
|
|||||||
...playbackStore,
|
...playbackStore,
|
||||||
...viewerStore,
|
...viewerStore,
|
||||||
|
|
||||||
|
async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void> {
|
||||||
|
const removedChannelIds = new Set(
|
||||||
|
[...playbackStore.channels.values()]
|
||||||
|
.filter((channel) => channel.groupId === groupId)
|
||||||
|
.map((channel) => channel.id),
|
||||||
|
);
|
||||||
|
for (const viewer of viewerStore.viewers.values()) {
|
||||||
|
if (viewer.channelId !== null && removedChannelIds.has(viewer.channelId)) {
|
||||||
|
await viewerStore.updateViewer(viewer.id, { channelId: fallbackChannelId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await playbackStore.deleteChannelGroup(groupId, fallbackChannelId);
|
||||||
|
},
|
||||||
|
|
||||||
addImageReference(imageId: string, kind: "slot" | "static-hold"): void {
|
addImageReference(imageId: string, kind: "slot" | "static-hold"): void {
|
||||||
const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 };
|
const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 };
|
||||||
references.set(
|
references.set(
|
||||||
|
|||||||
@@ -128,6 +128,34 @@ describe("viewers", () => {
|
|||||||
expect(store.viewers.size).toBe(1);
|
expect(store.viewers.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves an assigned display atomically when its channel group is deleted", async () => {
|
||||||
|
const original = (await store.listChannelGroups())[0];
|
||||||
|
if (!original) {
|
||||||
|
throw new Error("Expected the default group.");
|
||||||
|
}
|
||||||
|
const session = await openViewerSession({
|
||||||
|
at: new Date(clock),
|
||||||
|
deviceId: null,
|
||||||
|
newId,
|
||||||
|
store,
|
||||||
|
viewerKey: "kiosk-lobby-0003",
|
||||||
|
});
|
||||||
|
expect(original.channels.some((channel) => channel.id === session.channel.id)).toBe(true);
|
||||||
|
const fallbackGroupId = newId();
|
||||||
|
const fallbackChannelId = newId();
|
||||||
|
await store.createChannelGroup({
|
||||||
|
channelId: fallbackChannelId,
|
||||||
|
channelName: "Default",
|
||||||
|
groupId: fallbackGroupId,
|
||||||
|
name: "Foyer",
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.deleteChannelGroup(original.group.id, fallbackChannelId);
|
||||||
|
|
||||||
|
expect(await store.findChannelGroup(original.group.id)).toBeUndefined();
|
||||||
|
expect((await store.findViewer(session.viewer.id))?.channelId).toBe(fallbackChannelId);
|
||||||
|
});
|
||||||
|
|
||||||
it("stores only the safe device identifier a paired principal carries", async () => {
|
it("stores only the safe device identifier a paired principal carries", async () => {
|
||||||
const session = await openViewerSession({
|
const session = await openViewerSession({
|
||||||
at: new Date(clock),
|
at: new Date(clock),
|
||||||
|
|||||||
Reference in New Issue
Block a user