fix: Stage 0 Signage production readiness #18

Open
ekklesia-bot wants to merge 8 commits from stage0/signage-production-readiness into main
16 changed files with 3446 additions and 3034 deletions
Showing only changes of commit 7c17282616 - Show all commits
+1 -1
View File
@@ -20,7 +20,7 @@ export function DesktopOnly({ children }: { readonly children: ReactNode }): Rea
<div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note"> <div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note">
<h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2> <h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2>
<p className="signage-narrow-notice__body"> <p className="signage-narrow-notice__body">
{`Open the Ekklesia Dashboard on a display at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, channels, and playback.`} {`Open this page on a screen at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, displays, and playback.`}
</p> </p>
</div> </div>
<div className="signage-desktop" data-testid="signage-desktop"> <div className="signage-desktop" data-testid="signage-desktop">
+165 -168
View File
@@ -1,9 +1,9 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui"; import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui";
import { type ReactElement, useMemo, useState } from "react"; import { type ReactElement, useContext, useMemo, useState } from "react";
import { DesktopOnly } from "../browser/desktop-gate.js"; import { DesktopOnly } from "../browser/desktop-gate.js";
import { createSignageApi } from "./api.js"; import { createSignageApi } from "./api.js";
import { useResource } from "./hooks.js"; import { createResourceCache, ResourceCacheContext, useResource } from "./hooks.js";
import { DisplaysPanel } from "./panels/DisplaysPanel.js"; import { DisplaysPanel } from "./panels/DisplaysPanel.js";
import { ImagesPanel } from "./panels/ImagesPanel.js"; import { ImagesPanel } from "./panels/ImagesPanel.js";
import { PlaybackPanel } from "./panels/PlaybackPanel.js"; import { PlaybackPanel } from "./panels/PlaybackPanel.js";
@@ -22,175 +22,172 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
* Permission checks here decide what an operator is *offered*. The server decides what happens: a * Permission checks here decide what an operator is *offered*. The server decides what happens: a
* hidden button is a courtesy, and every route is enforced regardless of what this believes. * hidden button is a courtesy, and every route is enforced regardless of what this believes.
*/ */
export function SignageDashboard({ export function SignageDashboard(props: {
context, readonly context: ModuleUiHostContext;
copyText, readonly copyText: (value: string) => Promise<void>;
}: {
readonly context: ModuleUiHostContext;
readonly copyText: (value: string) => Promise<void>;
}): ReactElement { }): ReactElement {
const api = useMemo(() => createSignageApi(context.api), [context.api]); // One cache for the life of this screen: everything the panels have loaded, shared between them.
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing. const [cache] = useState(createResourceCache);
// A hidden screen is a usability choice; the server is what actually refuses, and making a return (
// request it will certainly refuse is noise in a log rather than security. <ResourceCacheContext.Provider value={cache}>
const mayView = context.permissions.has("signage.view"); <SignageWorkspace {...props} />
const settings = useResource( </ResourceCacheContext.Provider>
() => );
mayView }
? api.get<SettingsResponse>("settings")
: Promise.resolve(undefined),
[mayView],
);
const groups = useResource(
() =>
mayView
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
: Promise.resolve(undefined),
[mayView],
);
const [tab, setTab] = useState("playback");
const [groupId, setGroupId] = useState<string | undefined>(undefined);
const mayManageAssets = context.permissions.has("signage.assets.manage"); function SignageWorkspace({
const mayManageTimelines = context.permissions.has( context,
"signage.timelines.manage", copyText,
); }: {
const mayControl = context.permissions.has("signage.playback.control"); readonly context: ModuleUiHostContext;
const mayManageDisplays = context.permissions.has("signage.displays.manage"); readonly copyText: (value: string) => Promise<void>;
}): ReactElement {
const api = useMemo(() => createSignageApi(context.api), [context.api]);
const cache = useContext(ResourceCacheContext);
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
// A hidden screen is a usability choice; the server is what actually refuses, and making a
// request it will certainly refuse is noise in a log rather than security.
const mayView = context.permissions.has("signage.view");
const settings = useResource(
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
[mayView],
mayView ? "settings" : undefined,
);
const groups = useResource(
() =>
mayView
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
: Promise.resolve(undefined),
[mayView],
mayView ? "channel-groups" : undefined,
);
const [tab, setTab] = useState("playback");
const [groupId, setGroupId] = useState<string | undefined>(undefined);
if (!mayView) { const mayManageAssets = context.permissions.has("signage.assets.manage");
return ( const mayManageTimelines = context.permissions.has("signage.timelines.manage");
<EmptyState const mayControl = context.permissions.has("signage.playback.control");
description="Ask an administrator for the signage.view permission." const mayManageDisplays = context.permissions.has("signage.displays.manage");
title="No access to Signage"
/>
);
}
const selectedGroupId = if (!mayView) {
groupId ?? return (
(groups.state.status === "ready" <EmptyState
? groups.state.value?.groups[0]?.id description="Ask an administrator for the signage.view permission."
: undefined); title="No access to Signage"
const selectedGroup = />
groups.state.status === "ready" );
? groups.state.value?.groups.find( }
(candidate) => candidate.id === selectedGroupId,
)
: undefined;
return ( const selectedGroupId =
<> groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
<EkklesiaUiStyles /> const selectedGroup =
<DesktopOnly> groups.state.status === "ready"
<PageHeader ? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId)
description="Plan visual content, operate playback, and keep every screen connected." : undefined;
eyebrow="Media"
title="Signage" return (
/> <>
{renderLoadable( <EkklesiaUiStyles />
settings.state, <DesktopOnly>
(loaded) => <PageHeader
loaded === undefined ? undefined : ( description="Plan visual content, operate playback, and keep every screen connected."
<div className="signage-workspace"> eyebrow="Media"
<Tabs title="Signage"
label="Signage sections" />
onSelect={setTab} {renderLoadable(
selectedId={tab} settings.state,
tabs={[ (loaded) =>
{ loaded === undefined ? undefined : (
content: ( <div className="signage-workspace">
<PlaybackPanel <Tabs
api={api} label="Signage sections"
canControl={mayControl} onSelect={setTab}
context={context} selectedId={tab}
groupId={selectedGroupId} tabs={[
onGroupChange={setGroupId} {
settings={loaded} content: (
/> <PlaybackPanel
), api={api}
id: "playback", canControl={mayControl}
label: "Playback", context={context}
}, groupId={selectedGroupId}
{ onGroupChange={setGroupId}
content: ( settings={loaded}
<TimelinesPanel />
{...(selectedGroup ),
? { id: "playback",
activeGroupName: selectedGroup.name, label: "Playback",
activeTimelineId: },
selectedGroup.playback.activeTimelineId, {
} content: (
: {})} <TimelinesPanel
api={api} {...(selectedGroup
canManage={mayManageTimelines} ? {
onActivate={ activeGroupName: selectedGroup.name,
mayControl && selectedGroupId !== undefined activeTimelineId: selectedGroup.playback.activeTimelineId,
? async (timelineId) => { }
await api.send( : {})}
"POST", api={api}
`playback/${selectedGroupId}/activate`, canManage={mayManageTimelines}
{ onActivate={
timelineId, mayControl && selectedGroupId !== undefined
when: "now", ? async (timelineId) => {
}, await api.send("POST", `playback/${selectedGroupId}/activate`, {
); timelineId,
groups.reload(); when: "now",
setTab("playback"); });
} // Playback opens on what is now true, not on the
: undefined // timeline it was showing before.
} cache?.write(
settings={loaded} `playback/${selectedGroupId}`,
/> await api.get(`playback/${selectedGroupId}`),
), );
id: "timelines", groups.reload();
label: "Timelines", setTab("playback");
}, }
{ : undefined
content: ( }
<ImagesPanel settings={loaded}
api={api} />
canManage={mayManageAssets} ),
settings={loaded} id: "timelines",
/> label: "Timelines",
), },
id: "images", {
label: "Images", content: (
}, <ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
{ ),
content: ( id: "images",
<DisplaysPanel label: "Images",
api={api} },
canManage={mayManageDisplays} {
copyText={copyText} content: (
displaySetupUrl={context.display?.url} <DisplaysPanel
onGroupsChanged={() => { api={api}
setGroupId(undefined); canManage={mayManageDisplays}
groups.reload(); copyText={copyText}
}} displaySetupUrl={context.display?.url}
/> onGroupsChanged={() => {
), setGroupId(undefined);
id: "displays", groups.reload();
label: "Displays", }}
}, />
{ ),
content: ( id: "displays",
<ProvidersPanel label: "Displays",
api={api} },
context={context} {
settings={loaded} content: <ProvidersPanel api={api} context={context} settings={loaded} />,
/> id: "providers",
), label: "Providers",
id: "providers", },
label: "Providers", ]}
}, />
]} </div>
/> ),
</div> { label: "Loading Signage…", onRetry: settings.reload },
), )}
{ label: "Loading Signage…", onRetry: settings.reload }, </DesktopOnly>
)} </>
</DesktopOnly> );
</>
);
} }
+71 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { type SignageApiFailure, SignageRequestError } from "./api.js"; import { type SignageApiFailure, SignageRequestError } from "./api.js";
/** /**
@@ -21,15 +21,76 @@ export interface Resource<Value> {
reload(): void; reload(): void;
} }
/**
* The last value each Signage resource loaded, shared by every panel on the screen.
*
* The tabs mount only the selected panel, so without this every tab switch threw its data away,
* flashed a spinner, and collapsed the page to its height before growing again — the "jumping" an
* operator saw. It also let two panels disagree: activating a timeline from Playback left Timelines
* showing the old one as inactive until a reload. Now a remounted panel starts from what was last
* known and refreshes quietly, and a reload anywhere updates every panel reading the same resource.
*/
export interface ResourceCache {
read(key: string): { readonly value: unknown } | undefined;
write(key: string, value: unknown): void;
subscribe(key: string, listener: (value: unknown) => void): () => void;
}
export function createResourceCache(): ResourceCache {
const values = new Map<string, { readonly value: unknown }>();
const listeners = new Map<string, Set<(value: unknown) => void>>();
return {
read: (key) => values.get(key),
subscribe(key, listener) {
const set = listeners.get(key) ?? new Set();
set.add(listener);
listeners.set(key, set);
return () => {
set.delete(listener);
if (set.size === 0) {
listeners.delete(key);
}
};
},
write(key, value) {
values.set(key, { value });
for (const listener of [...(listeners.get(key) ?? [])]) {
listener(value);
}
},
};
}
export const ResourceCacheContext = createContext<ResourceCache | undefined>(undefined);
export function useResource<Value>( export function useResource<Value>(
load: () => Promise<Value>, load: () => Promise<Value>,
dependencies: readonly unknown[], dependencies: readonly unknown[],
/** Shares this resource with every other panel that names the same key. */
key?: string,
): Resource<Value> { ): Resource<Value> {
const [state, setState] = useState<Loadable<Value>>({ status: "loading" }); const cache = useContext(ResourceCacheContext);
const cached = key === undefined ? undefined : cache?.read(key);
const [state, setState] = useState<Loadable<Value>>(
cached === undefined
? { status: "loading" }
: { status: "ready", value: cached.value as Value },
);
const generation = useRef(0); const generation = useRef(0);
const loadRef = useRef(load); const loadRef = useRef(load);
loadRef.current = load; loadRef.current = load;
useEffect(() => {
if (key === undefined || !cache) {
return;
}
const current = cache.read(key);
if (current !== undefined) {
setState({ status: "ready", value: current.value as Value });
}
return cache.subscribe(key, (value) => setState({ status: "ready", value: value as Value }));
}, [cache, key]);
const run = useCallback(() => { const run = useCallback(() => {
generation.current += 1; generation.current += 1;
const token = generation.current; const token = generation.current;
@@ -38,7 +99,13 @@ export function useResource<Value>(
.then((value) => { .then((value) => {
// A response from a request the panel has already moved on from must never overwrite a // A response from a request the panel has already moved on from must never overwrite a
// newer one. // newer one.
if (token === generation.current) { if (token !== generation.current) {
return;
}
if (key !== undefined && cache) {
// Every panel subscribed to this key, including this one, is updated by the write.
cache.write(key, value);
} else {
setState({ status: "ready", value }); setState({ status: "ready", value });
} }
}) })
@@ -58,7 +125,7 @@ export function useResource<Value>(
status: "failed", status: "failed",
}); });
}); });
}, []); }, [cache, key]);
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs // The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
// change, which is the whole reason this takes one. // change, which is the whole reason this takes one.
+18 -20
View File
@@ -14,24 +14,22 @@ import ownStyles from "./styles.css";
* rather than bundled (ADR 0039). * rather than bundled (ADR 0039).
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "signage", id: "signage",
version: "0.1.4", version: "0.1.4",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`, styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
root.render( root.render(
<SignageDashboard <SignageDashboard
context={context} context={context}
copyText={async (value) => { copyText={async (value) => {
if (globalThis.navigator.clipboard === undefined) { if (globalThis.navigator.clipboard === undefined) {
throw new Error( throw new Error("Clipboard access is not available in this browser.");
"Clipboard access is not available in this browser.", }
); await globalThis.navigator.clipboard.writeText(value);
} }}
await globalThis.navigator.clipboard.writeText(value); />,
}} );
/>, return () => root.unmount();
); },
return () => root.unmount();
},
}); });
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -9,7 +9,7 @@ import {
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
import { useAction, useResource } from "../hooks.js"; import { useAction, useResource } from "../hooks.js";
import type { ImageRecord, SettingsResponse } from "../types.js"; import type { ImageRecord, SettingsResponse } from "../types.js";
import { FailureNote, formatInstant, renderLoadable } from "./shared.js"; import { ConfirmButton, FailureNote, formatInstant, renderLoadable } from "./shared.js";
/** /**
* The native image library and its upload queue. * The native image library and its upload queue.
@@ -28,7 +28,11 @@ export function ImagesPanel({
readonly settings: SettingsResponse; readonly settings: SettingsResponse;
readonly canManage: boolean; readonly canManage: boolean;
}): ReactElement { }): ReactElement {
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []); const images = useResource(
() => api.get<{ images: readonly ImageRecord[] }>("images"),
[],
"images",
);
const [uploads, setUploads] = useState<readonly UploadItem[]>([]); const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
const queueRef = useRef<UploadQueue | undefined>(undefined); const queueRef = useRef<UploadQueue | undefined>(undefined);
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined); const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
@@ -204,13 +208,15 @@ export function ImagesPanel({
Rename Rename
</Button> </Button>
)} )}
<Button <ConfirmButton
confirmLabel="Delete this image"
disabled={remove.pending} disabled={remove.pending}
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))} label="Delete"
variant="danger" onConfirm={() =>
> remove.run(() => api.send("DELETE", `images/${image.id}`))
Delete }
</Button> testId={`signage-image-delete-${image.id}`}
/>
</div> </div>
) : undefined} ) : undefined}
</li> </li>
+326 -331
View File
@@ -1,29 +1,16 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { import { ActionBar, Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
ActionBar, import { type ReactElement, useEffect, useRef, useState } from "react";
Badge,
Button,
EmptyState,
Field,
Panel,
Select,
} from "@ekklesia/ui";
import { type ReactElement, useState } from "react";
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js"; import { useAction, useResource, useTick } from "../hooks.js";
import type { import type {
ChannelGroupView, ChannelGroupView,
PlaybackDetailView, PlaybackDetailView,
SettingsResponse, SettingsResponse,
TimelineSummary, TimelineSummary,
} from "../types.js"; } from "../types.js";
import { PreviewPanel } from "./PreviewPanel.js"; import { PreviewPanel } from "./PreviewPanel.js";
import { import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
FailureNote,
formatDuration,
formatInstant,
renderLoadable,
} from "./shared.js";
/** /**
* Live transport for one channel group. * Live transport for one channel group.
@@ -33,325 +20,333 @@ import {
* button, and neither can produce a state the other did not ask for. * button, and neither can produce a state the other did not ask for.
*/ */
export function PlaybackPanel({ export function PlaybackPanel({
api, api,
context, context,
settings, settings,
canControl, canControl,
groupId, groupId,
onGroupChange, onGroupChange,
}: { }: {
readonly api: SignageApi; readonly api: SignageApi;
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse; readonly settings: SettingsResponse;
readonly canControl: boolean; readonly canControl: boolean;
readonly groupId: string | undefined; readonly groupId: string | undefined;
readonly onGroupChange: (groupId: string) => void; readonly onGroupChange: (groupId: string) => void;
}): ReactElement { }): ReactElement {
const groups = useResource( const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
[], [],
); "channel-groups",
const timelines = useResource( );
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), const timelines = useResource(
[], () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
); [],
const detail = useResource( "timelines",
() => );
groupId === undefined const detail = useResource(
? Promise.resolve(undefined) () =>
: api.get<PlaybackDetailView>(`playback/${groupId}`), groupId === undefined
[groupId], ? Promise.resolve(undefined)
); : api.get<PlaybackDetailView>(`playback/${groupId}`),
const command = useAction(() => { [groupId],
detail.reload(); groupId === undefined ? undefined : `playback/${groupId}`,
groups.reload(); );
}); const command = useAction(() => {
const [activateId, setActivateId] = useState(""); detail.reload();
const previewChannelId = groups.reload();
groups.state.status === "ready" });
? (groups.state.value.groups const [activateId, setActivateId] = useState("");
.find((group) => group.id === groupId) const previewChannelId =
?.channels.find((channel) => channel.isDefault)?.id ?? undefined) groups.state.status === "ready"
: undefined; ? (groups.state.value.groups
// A one-second tick so the remaining time counts down rather than sitting still between refreshes. .find((group) => group.id === groupId)
useTick(1000); ?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
: undefined;
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
useTick(1000);
useManagementUpdates(context, groupId, () => {
detail.reload();
groups.reload();
});
function run(path: string, body?: unknown): void { function run(path: string, body?: unknown): void {
if (groupId === undefined) { if (groupId === undefined) {
return; return;
} }
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body)); command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
} }
function timelineLabel(timelineId: string): string { function timelineLabel(timelineId: string): string {
if (timelines.state.status !== "ready") { if (timelines.state.status !== "ready") {
return timelineId; return timelineId;
} }
return ( return (
timelines.state.value.timelines.find( timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ??
(timeline) => timeline.id === timelineId, timelineId
)?.name ?? timelineId );
); }
}
return ( return (
<div className="signage-columns signage-playback-layout"> <div className="signage-columns signage-playback-layout">
<Panel <Panel
actions={renderLoadable( actions={renderLoadable(
groups.state, groups.state,
(value) => ( (value) => (
<Field label="Channel group"> <Field label="Channel group">
{(control) => ( {(control) => (
<Select <Select
{...control} {...control}
onChange={(event) => onGroupChange(event.target.value)} onChange={(event) => onGroupChange(event.target.value)}
value={groupId ?? ""} value={groupId ?? ""}
> >
{value.groups.map((group) => ( {value.groups.map((group) => (
<option key={group.id} value={group.id}> <option key={group.id} value={group.id}>
{group.name} {group.name}
</option> </option>
))} ))}
</Select> </Select>
)} )}
</Field> </Field>
), ),
{ label: "Loading groups…" }, { label: "Loading groups…" },
)} )}
description="One clock for every channel in the group." description="One clock for every channel in the group."
title="Playback" title="Playback"
> >
{groupId === undefined ? ( {groupId === undefined ? (
<EmptyState title="No channel group selected" /> <EmptyState title="No channel group selected" />
) : ( ) : (
renderLoadable( renderLoadable(
detail.state, detail.state,
(value) => (value) =>
value === undefined ? ( value === undefined ? (
<EmptyState title="No channel group selected" /> <EmptyState title="No channel group selected" />
) : ( ) : (
<div <div className="signage-playback" data-testid="signage-playback">
className="signage-playback" <div className="signage-playback__status">
data-testid="signage-playback" {value.snapshot.activeTimelineId === null ? undefined : (
> <Badge tone="success">
<div className="signage-playback__status"> {`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
{value.snapshot.activeTimelineId === null ? undefined : ( </Badge>
<Badge tone="success"> )}
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`} <Badge
</Badge> tone={
)} value.snapshot.transport === "playing"
<Badge ? "success"
tone={ : value.snapshot.transport === "idle"
value.snapshot.transport === "playing" ? "warning"
? "success" : "info"
: value.snapshot.transport === "idle" }
? "warning" >
: "info" {value.snapshot.transport}
} </Badge>
> {value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
{value.snapshot.transport} {value.current?.stale === true ? (
</Badge> <Badge tone="warning">Stale</Badge>
{value.snapshot.blackout ? ( ) : undefined}
<Badge tone="danger">Blackout</Badge> {value.current?.withdrawn === true ? (
) : undefined} <Badge tone="warning">Withdrawn</Badge>
{value.current?.stale === true ? ( ) : undefined}
<Badge tone="warning">Stale</Badge> {value.snapshot.pendingTimelineId === null ? undefined : (
) : undefined} <Badge tone="info">Activation queued</Badge>
{value.current?.withdrawn === true ? ( )}
<Badge tone="warning">Withdrawn</Badge> </div>
) : undefined}
{value.snapshot.pendingTimelineId === null ? undefined : (
<Badge tone="info">Activation queued</Badge>
)}
</div>
{value.current === null ? ( {value.current === null ? (
<EmptyState <EmptyState
description="Nothing is currently resolvable. A display shows its safe slate." description="Nothing is currently resolvable. A display shows its safe slate."
title="Nothing on screen" title="Nothing on screen"
/> />
) : ( ) : (
<section className="signage-playback__now"> <section className="signage-playback__now">
<h3>Now showing</h3> <h3>Now showing</h3>
<dl <dl className="signage-playback__current" data-testid="signage-current">
className="signage-playback__current" <dt>Provider</dt>
data-testid="signage-current" <dd>{value.current.providerId}</dd>
> <dt>Item</dt>
<dt>Provider</dt> <dd>{value.current.itemId}</dd>
<dd>{value.current.providerId}</dd> <dt>Renderer</dt>
<dt>Item</dt> <dd>{value.current.rendererContributionId ?? "—"}</dd>
<dd>{value.current.itemId}</dd> <dt>Duration</dt>
<dt>Renderer</dt> <dd>{formatDuration(value.current.durationMs)}</dd>
<dd>{value.current.rendererContributionId ?? "—"}</dd> <dt>Remaining</dt>
<dt>Duration</dt> <dd data-testid="signage-remaining">
<dd>{formatDuration(value.current.durationMs)}</dd> {value.snapshot.remainingMs === null
<dt>Remaining</dt> ? "—"
<dd data-testid="signage-remaining"> : formatDuration(value.snapshot.remainingMs)}
{value.snapshot.remainingMs === null </dd>
? "—" <dt>Started</dt>
: formatDuration(value.snapshot.remainingMs)} <dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
</dd> </dl>
<dt>Started</dt> </section>
<dd> )}
{formatInstant(
value.current.startedAtMs,
settings.timezone,
)}
</dd>
</dl>
</section>
)}
{canControl ? ( {canControl ? (
<ActionBar <ActionBar
className="signage-playback__controls" className="signage-playback__controls"
data-testid="signage-controls" data-testid="signage-controls"
label="Playback controls" label="Playback controls"
> >
<Button <Button disabled={command.pending} onClick={() => run("previous")}>
disabled={command.pending} Previous
onClick={() => run("previous")} </Button>
> {value.snapshot.transport === "paused" ? (
Previous <Button
</Button> disabled={command.pending}
{value.snapshot.transport === "paused" ? ( onClick={() => run("resume")}
<Button variant="primary"
disabled={command.pending} >
onClick={() => run("resume")} Resume
variant="primary" </Button>
> ) : (
Resume <Button disabled={command.pending} onClick={() => run("pause")}>
</Button> Pause
) : ( </Button>
<Button )}
disabled={command.pending} <Button disabled={command.pending} onClick={() => run("next")}>
onClick={() => run("pause")} Next
> </Button>
Pause <Button
</Button> disabled={command.pending}
)} onClick={() => run("blackout", { on: !value.snapshot.blackout })}
<Button variant={value.snapshot.blackout ? "primary" : "secondary"}
disabled={command.pending} >
onClick={() => run("next")} {value.snapshot.blackout ? "End blackout" : "Blackout"}
> </Button>
Next {value.snapshot.staticAssetId === null ? undefined : (
</Button> <Button
<Button disabled={command.pending}
disabled={command.pending} onClick={() => run("static", { assetId: null })}
onClick={() => >
run("blackout", { on: !value.snapshot.blackout }) Release hold
} </Button>
variant={ )}
value.snapshot.blackout ? "primary" : "secondary" <Button disabled={command.pending} onClick={() => run("reload")}>
} Reload content
> </Button>
{value.snapshot.blackout ? "End blackout" : "Blackout"} </ActionBar>
</Button> ) : undefined}
{value.snapshot.staticAssetId === null ? undefined : (
<Button
disabled={command.pending}
onClick={() => run("static", { assetId: null })}
>
Release hold
</Button>
)}
<Button
disabled={command.pending}
onClick={() => run("reload")}
>
Reload content
</Button>
</ActionBar>
) : undefined}
{canControl ? ( {canControl ? (
<div className="signage-playback__activate"> <div className="signage-playback__activate">
{renderLoadable( {renderLoadable(
timelines.state, timelines.state,
(loaded) => ( (loaded) => (
<> <>
<Field label="Activate timeline"> <Field label="Activate timeline">
{(control) => ( {(control) => (
<Select <Select
{...control} {...control}
onChange={(event) => onChange={(event) => setActivateId(event.target.value)}
setActivateId(event.target.value) value={activateId}
} >
value={activateId} <option value="">Choose a timeline…</option>
> {loaded.timelines.map((timeline) => (
<option value="">Choose a timeline…</option> <option key={timeline.id} value={timeline.id}>
{loaded.timelines.map((timeline) => ( {timeline.name}
<option </option>
key={timeline.id} ))}
value={timeline.id} </Select>
> )}
{timeline.name} </Field>
</option> <Button
))} disabled={command.pending || activateId === ""}
</Select> onClick={() =>
)} run("activate", {
</Field> timelineId: activateId,
<Button when: "now",
disabled={command.pending || activateId === ""} })
onClick={() => }
run("activate", { variant="primary"
timelineId: activateId, >
when: "now", Now
}) </Button>
} <Button
variant="primary" disabled={command.pending || activateId === ""}
> onClick={() =>
Now run("activate", {
</Button> timelineId: activateId,
<Button when: "next-boundary",
disabled={command.pending || activateId === ""} })
onClick={() => }
run("activate", { >
timelineId: activateId, At next slot
when: "next-boundary", </Button>
}) </>
} ),
> { label: "Loading timelines…" },
At next slot )}
</Button> </div>
</> ) : undefined}
),
{ label: "Loading timelines…" },
)}
</div>
) : undefined}
{value.history.length === 0 ? undefined : ( {value.history.length === 0 ? undefined : (
<div className="signage-playback__history"> <div className="signage-playback__history">
<h4 className="signage-editor__subtitle"> <h4 className="signage-editor__subtitle">Recently played</h4>
Recently played <ul className="ek-list">
</h4> {value.history.slice(0, 8).map((entry) => (
<ul className="ek-list"> <li className="ek-list__item signage-row" key={entry.id}>
{value.history.slice(0, 8).map((entry) => ( <span className="signage-row__name">{entry.itemId}</span>
<li <span className="signage-playback__history-meta">
className="ek-list__item signage-row" {`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
key={entry.id} </span>
> </li>
<span className="signage-row__name"> ))}
{entry.itemId} </ul>
</span> </div>
<span className="signage-playback__history-meta"> )}
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`} </div>
</span> ),
</li> { label: "Loading playback…", onRetry: detail.reload },
))} )
</ul> )}
</div> <FailureNote failure={command.failure} />
)} </Panel>
</div>
),
{ label: "Loading playback…", onRetry: detail.reload },
)
)}
<FailureNote failure={command.failure} />
</Panel>
<PreviewPanel api={api} channelId={previewChannelId} context={context} /> <PreviewPanel api={api} channelId={previewChannelId} context={context} />
</div> </div>
); );
}
/**
* Follows Signage's management realtime channel, which carries revisions only (ADR 0005), and asks
* for fresh detail when this group's playback actually changed — so "Now showing" follows the wall
* instead of freezing until an operator presses something. One subscription per mounted panel,
* closed when the panel goes away.
*/
function useManagementUpdates(
context: ModuleUiHostContext,
groupId: string | undefined,
onChange: () => void,
): void {
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (groupId === undefined) {
return;
}
let seen: string | undefined;
const subscription = context.realtime.subscribe("management-state", {
onSnapshot: (payload) => observe(payload),
onUpdate: (payload) => observe(payload),
});
function observe(payload: unknown): void {
const groups = (payload as { groups?: unknown } | null)?.groups;
if (!Array.isArray(groups)) {
return;
}
const mine = groups.find((group: { groupId?: unknown }) => group?.groupId === groupId) as
| { revision?: unknown; selectionRevision?: unknown }
| undefined;
if (!mine) {
return;
}
const marker = `${String(mine.revision)}:${String(mine.selectionRevision)}`;
if (seen !== undefined && marker !== seen) {
onChangeRef.current();
}
seen = marker;
}
return () => subscription.close();
}, [context, groupId]);
} }
+11 -1
View File
@@ -2,6 +2,7 @@ import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EmptyState, Panel } from "@ekklesia/ui"; import { EmptyState, Panel } from "@ekklesia/ui";
import { type ReactElement, useEffect, useRef } from "react"; import { type ReactElement, useEffect, useRef } from "react";
import { SignageSlate } from "../../browser/slate.js"; import { SignageSlate } from "../../browser/slate.js";
import { createMemoryMediaCache, createMemoryStore } from "../../browser/storage.js";
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js"; import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
@@ -36,7 +37,16 @@ export function PreviewPanel({
return; return;
} }
let stopped = false; let stopped = false;
const controller = createDisplayController({ context, root, telemetry: false }); // The display's own rendering code, but nothing it keeps: a management browser is not a wall,
// so it persists no snapshot and caches no image bytes, and never restores one channel's last
// frame into another channel's preview.
const controller = createDisplayController({
context,
media: createMemoryMediaCache(),
root,
store: createMemoryStore(),
telemetry: false,
});
let handle: ReturnType<typeof setTimeout> | undefined; let handle: ReturnType<typeof setTimeout> | undefined;
async function pump(): Promise<void> { async function pump(): Promise<void> {
+89 -102
View File
@@ -1,13 +1,13 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { import {
Badge, Badge,
EmptyState, EmptyState,
Panel, Panel,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
TableRow, TableRow,
} from "@ekklesia/ui"; } from "@ekklesia/ui";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
@@ -31,101 +31,88 @@ const SLIDE_RENDERER_CAPABILITY = "signage.slide-renderer";
* exactly that, which is the difference between "this is broken" and "this needs an update". * exactly that, which is the difference between "this is broken" and "this needs an update".
*/ */
export function ProvidersPanel({ export function ProvidersPanel({
api, api,
context, context,
settings, settings,
}: { }: {
readonly api: SignageApi; readonly api: SignageApi;
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse; readonly settings: SettingsResponse;
}): ReactElement { }): ReactElement {
const providers = useResource( const providers = useResource(
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"), () => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
[], [],
); "providers",
const published = new Set( );
context.contributions const published = new Set(
.list(SLIDE_RENDERER_CAPABILITY) context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
.map((contribution) => contribution.id), );
);
return ( return (
<Panel <Panel
actions={ actions={<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>}
<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge> description="Running content sources available to timelines."
} title="Content providers"
description="Running content sources available to timelines." >
title="Content providers" {renderLoadable(
> providers.state,
{renderLoadable( (value) =>
providers.state, value.providers.length === 0 ? (
(value) => <EmptyState
value.providers.length === 0 ? ( description="Signage works on its own with native images. Install a content module to add more."
<EmptyState title="No content providers registered"
description="Signage works on its own with native images. Install a content module to add more." />
title="No content providers registered" ) : (
/> <Table label="Provider health">
) : ( <TableHead>
<Table label="Provider health"> <TableRow>
<TableHead> <TableCell header>Provider</TableCell>
<TableRow> <TableCell header>Contract</TableCell>
<TableCell header>Provider</TableCell> <TableCell header>Renderer</TableCell>
<TableCell header>Contract</TableCell> <TableCell header>Health</TableCell>
<TableCell header>Renderer</TableCell> <TableCell header>Last success</TableCell>
<TableCell header>Health</TableCell> </TableRow>
<TableCell header>Last success</TableCell> </TableHead>
</TableRow> <TableBody>
</TableHead> {value.providers.map((provider) => {
<TableBody> const rendererPublished = published.has(provider.renderer.contributionId);
{value.providers.map((provider) => { const isNative = provider.renderer.contributionId === "signage.native-image";
const rendererPublished = published.has( return (
provider.renderer.contributionId, <TableRow key={provider.providerId}>
); <TableCell>
const isNative = <span className="signage-provider__id">{provider.providerId}</span>
provider.renderer.contributionId === "signage.native-image"; <span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
return ( </TableCell>
<TableRow key={provider.providerId}> <TableCell>{provider.contractVersion}</TableCell>
<TableCell> <TableCell>
<span className="signage-provider__id"> {isNative ? (
{provider.providerId} <Badge tone="info">Built in</Badge>
</span> ) : rendererPublished ? (
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span> <Badge tone="success">{provider.renderer.contributionId}</Badge>
</TableCell> ) : (
<TableCell>{provider.contractVersion}</TableCell> <Badge tone="warning">Renderer not published</Badge>
<TableCell> )}
{isNative ? ( </TableCell>
<Badge tone="info">Built in</Badge> <TableCell>
) : rendererPublished ? ( {provider.consecutiveFailures === 0 ? (
<Badge tone="success"> <Badge tone="success">Healthy</Badge>
{provider.renderer.contributionId} ) : (
</Badge> <Badge tone="danger">
) : ( {`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
<Badge tone="warning">Renderer not published</Badge> </Badge>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{provider.consecutiveFailures === 0 ? ( {formatInstant(provider.lastSuccessAt, settings.timezone)}
<Badge tone="success">Healthy</Badge> </TableCell>
) : ( </TableRow>
<Badge tone="danger"> );
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`} })}
</Badge> </TableBody>
)} </Table>
</TableCell> ),
<TableCell> { label: "Loading providers…", onRetry: providers.reload },
{formatInstant( )}
provider.lastSuccessAt, </Panel>
settings.timezone, );
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
),
{ label: "Loading providers…", onRetry: providers.reload },
)}
</Panel>
);
} }
File diff suppressed because it is too large Load Diff
+79 -2
View File
@@ -1,5 +1,5 @@
import { Badge, ErrorState, LoadingState } from "@ekklesia/ui"; import { Badge, Button, ErrorState, LoadingState } from "@ekklesia/ui";
import type { ReactElement, ReactNode } from "react"; import { type ReactElement, type ReactNode, useEffect, useState } from "react";
import type { SignageApiFailure } from "../api.js"; import type { SignageApiFailure } from "../api.js";
import type { Loadable } from "../hooks.js"; import type { Loadable } from "../hooks.js";
@@ -89,3 +89,80 @@ export function formatSince(atMs: number | null, nowMs: number): string {
export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement { export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement {
return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>; return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
} }
/** How long an armed destructive button waits for its second press before standing down. */
export const CONFIRM_RESET_MS = 6000;
/**
* A moderate destructive action, confirmed in place (DESIGN §21.3) — never with a browser dialog.
*
* The first press arms it: the button says plainly what will happen and a Cancel appears beside it.
* A second, deliberate press performs the action. Walking away, pressing Cancel, or Escape disarms
* it, so an armed button can never be left waiting for an accidental click.
*/
export function ConfirmButton({
label,
confirmLabel,
disabled,
onConfirm,
testId,
}: {
readonly label: string;
/** What the second press does, in words: "Delete this image", not "Are you sure?". */
readonly confirmLabel: string;
readonly disabled?: boolean;
readonly onConfirm: () => void;
readonly testId?: string;
}): ReactElement {
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!armed) {
return;
}
const timer = setTimeout(() => setArmed(false), CONFIRM_RESET_MS);
return () => clearTimeout(timer);
}, [armed]);
if (!armed) {
return (
<Button
data-testid={testId}
disabled={disabled}
onClick={() => setArmed(true)}
variant="danger"
>
{label}
</Button>
);
}
return (
<fieldset
className="signage-confirm-inline"
onKeyDown={(event) => {
if (event.key === "Escape") {
setArmed(false);
}
}}
>
<Button
autoFocus
data-testid={testId === undefined ? undefined : `${testId}-confirm`}
disabled={disabled}
onClick={() => {
setArmed(false);
onConfirm();
}}
variant="danger"
>
{confirmLabel}
</Button>
<Button
data-testid={testId === undefined ? undefined : `${testId}-cancel`}
onClick={() => setArmed(false)}
variant="quiet"
>
Cancel
</Button>
</fieldset>
);
}
+422 -414
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+414
View File
@@ -0,0 +1,414 @@
// @vitest-environment happy-dom
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { audienceKeyFor } from "../../src/ui/panels/DisplaysPanel.js";
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
import {
createFakeHost,
FAKE_SETTINGS,
type FakeHostRoute,
jsonResponse,
} from "./support/fake-host.js";
/**
* Stage 0 production-readiness regressions for the management screen.
*
* Each case is a defect an operator met: an activation that looked lost after changing tabs, a
* screen that collapsed to a spinner on every tab switch, deletions with no confirmation, an
* internal key presented as something to fill in, and test displays nobody could remove.
*/
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
async function settle(): Promise<void> {
for (let index = 0; index < 8; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
async function click(element: Element | null | undefined): Promise<void> {
if (!element) {
throw new Error("Expected an element to click.");
}
await act(async () => (element as HTMLElement).click());
await settle();
}
function button(label: string): HTMLButtonElement | undefined {
return [...container.querySelectorAll("button")].find(
(candidate) => candidate.textContent?.trim() === label,
) as HTMLButtonElement | undefined;
}
function tab(label: string): Element | undefined {
return [...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === label,
);
}
async function choose(select: HTMLSelectElement, value: string): Promise<void> {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
await act(async () => {
setter?.call(select, value);
select.dispatchEvent(new Event("change", { bubbles: true }));
});
}
async function type(input: HTMLInputElement, value: string): Promise<void> {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
await act(async () => {
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function timeline(id: string, name: string) {
return {
description: null,
eligibleFrom: null,
eligibleUntil: null,
id,
name,
revision: 1,
updatedAt: "2026-10-01T15:00:00.000Z",
};
}
function group(activeTimelineId: string | null) {
return {
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }],
id: "group-1",
name: "Main",
playback: {
activeTimelineId,
blackout: false,
currentSelectionId: null,
currentSlotId: null,
deadlineAtMs: null,
groupId: "group-1",
pendingTimelineId: null,
remainingMs: null,
revision: activeTimelineId === null ? 1 : 2,
scheduledActivationAtMs: null,
scheduledTimelineId: null,
selectionRevision: null,
serverNowMs: 1_700_000_000_000,
startedAtMs: null,
staticAssetId: null,
transport: activeTimelineId === null ? ("idle" as const) : ("playing" as const),
},
revision: 1,
};
}
/** A small, stateful Signage backend: activation really changes what later reads return. */
function backend(extra: readonly FakeHostRoute[] = []) {
let active: string | null = null;
const sent: string[] = [];
const routes: FakeHostRoute[] = [
...extra,
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [group(active)] }),
},
{
method: "GET",
path: "timelines",
respond: () =>
jsonResponse({
timelines: [timeline("timeline-1", "Sunday"), timeline("timeline-2", "Easter")],
}),
},
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) },
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) },
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse({ current: null, history: [], snapshot: group(active).playback }),
},
{ method: "GET", path: /^channels\/.*\/display-state$/u, respond: () => jsonResponse({}) },
{
method: "POST",
path: "playback/group-1/activate",
respond: ({ init }) => {
active = (JSON.parse(String(init?.body)) as { timelineId: string }).timelineId;
sent.push(`activate ${active}`);
return jsonResponse(group(active).playback);
},
},
];
return { routes, sent };
}
function mount(host: ReturnType<typeof createFakeHost>) {
return act(async () => {
root.render(<SignageDashboard context={host.context} copyText={() => Promise.resolve()} />);
});
}
describe("Signage dashboard — Stage 0 regressions", () => {
it("shows a timeline activated on Playback as active on Timelines too", async () => {
const server = backend();
const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control", "signage.timelines.manage"],
routes: server.routes,
});
await mount(host);
await settle();
const activate = [...container.querySelectorAll("select")].find((select) =>
[...select.options].some((option) => option.textContent === "Choose a timeline…"),
) as HTMLSelectElement;
await choose(activate, "timeline-2");
await click(button("Now"));
expect(server.sent).toEqual(["activate timeline-2"]);
await click(tab("Timelines"));
const row = [...container.querySelectorAll("tr")].find((candidate) =>
candidate.textContent?.includes("Easter"),
);
expect(row?.textContent).toContain("Active in Main");
expect(container.querySelector("tr")?.parentElement).not.toBeNull();
});
it("keeps what it loaded when switching tabs, instead of flashing back to a loading state", async () => {
let slow = false;
const server = backend([
{
method: "GET",
path: "timelines",
respond: () =>
slow
? new Promise<Response>(() => undefined)
: jsonResponse({ timelines: [timeline("timeline-1", "Sunday")] }),
},
]);
const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes });
await mount(host);
await settle();
await click(tab("Timelines"));
expect(container.textContent).toContain("Sunday");
// The next load never finishes. Coming back must still show the last known list.
slow = true;
await click(tab("Images"));
await click(tab("Timelines"));
expect(container.textContent).toContain("Sunday");
expect(container.textContent).not.toContain("Loading timelines…");
});
it("deletes an image only after a second, deliberate press, and Cancel stands it down", async () => {
const deleted: string[] = [];
const image = {
byteSize: 1000,
contentType: "image/png",
createdAt: "2026-10-01T15:00:00.000Z",
displayName: "Welcome",
expiresAt: null,
height: 1080,
id: "image-1",
originalFilename: "welcome.png",
renditions: [],
status: "ready",
width: 1920,
};
const server = backend([
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [image] }) },
{
method: "DELETE",
path: "images/image-1",
respond: ({ path }) => {
deleted.push(path);
return new Response(null, { status: 204 });
},
},
]);
const host = createFakeHost({
permissions: ["signage.view", "signage.assets.manage"],
routes: server.routes,
});
await mount(host);
await settle();
await click(tab("Images"));
await click(container.querySelector("[data-testid='signage-image-delete-image-1']"));
expect(deleted).toEqual([]);
expect(button("Delete this image")).toBeDefined();
await click(container.querySelector("[data-testid='signage-image-delete-image-1-cancel']"));
expect(button("Delete this image")).toBeUndefined();
expect(deleted).toEqual([]);
await click(container.querySelector("[data-testid='signage-image-delete-image-1']"));
await click(container.querySelector("[data-testid='signage-image-delete-image-1-confirm']"));
expect(deleted).toEqual(["images/image-1"]);
});
it("adds an audience from its name alone, deriving the content key", async () => {
const posted: unknown[] = [];
const server = backend([
{
method: "POST",
path: "channel-groups/group-1/channels",
respond: ({ init }) => {
posted.push(JSON.parse(String(init?.body)));
return jsonResponse({}, 201);
},
},
]);
const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"],
routes: server.routes,
});
await mount(host);
await settle();
await click(tab("Displays"));
const name = container.querySelector("input[placeholder='Online']") as HTMLInputElement;
await type(name, "Watching Online");
await click(button("Add audience"));
expect(posted).toEqual([{ key: "watching-online", name: "Watching Online" }]);
});
it("labels a signed-in browser and removes an offline entry only after confirmation", async () => {
const removed: string[] = [];
const viewer = {
cachedAssetCount: 0,
channelId: "chan-1",
deviceId: null,
id: "viewer-1",
lastError: null,
lastSeenAtMs: Date.now() - 600_000,
name: "New display",
offlineReady: false,
online: false,
reportedContributionId: null,
reportedProviderId: null,
reportedSelectionId: null,
reportedTimelineId: null,
};
const server = backend([
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [viewer] }) },
{
method: "DELETE",
path: "viewers/viewer-1",
respond: ({ path }) => {
removed.push(path);
return new Response(null, { status: 204 });
},
},
]);
const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"],
routes: server.routes,
});
await mount(host);
await settle();
await click(tab("Displays"));
expect(container.textContent).toContain("Signed-in browser");
await click(container.querySelector("[data-testid='signage-display-remove-viewer-1']"));
expect(removed).toEqual([]);
await click(container.querySelector("[data-testid='signage-display-remove-viewer-1-confirm']"));
expect(removed).toEqual(["viewers/viewer-1"]);
});
it("follows playback changes on the management channel, and stops when Playback is left", async () => {
const subscriptions: { closed: boolean; onUpdate: ((payload: unknown) => void) | undefined }[] =
[];
const server = backend();
const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes });
Object.assign(host.context.realtime, {
subscribe(channel: string, handlers: { onUpdate?: (payload: unknown) => void }) {
const entry = { closed: false, onUpdate: handlers.onUpdate };
if (channel === "management-state") {
subscriptions.push(entry);
}
return { close: () => (entry.closed = true) };
},
});
await mount(host);
await settle();
const detailReads = () =>
host.requests.filter((request) => request.path === "playback/group-1").length;
const before = detailReads();
const live = subscriptions.find((entry) => !entry.closed);
await act(async () => {
live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 1, selectionRevision: null }] });
live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 2, selectionRevision: 4 }] });
});
await settle();
expect(detailReads()).toBeGreaterThan(before);
await click(tab("Images"));
expect(subscriptions.every((entry) => entry.closed)).toBe(true);
});
});
describe("audience content keys", () => {
it("derives a valid, unique key that is never the reserved default", () => {
expect(audienceKeyFor("Online", [])).toBe("online");
expect(audienceKeyFor("Online", ["online"])).toBe("online-2");
expect(audienceKeyFor("Default", [])).toBe("default-2");
expect(audienceKeyFor("2nd service", [])).toBe("audience-2nd-service");
expect(audienceKeyFor(" ", [])).toBe("audience");
expect(audienceKeyFor("Café — Room!", [])).toBe("cafe-room");
});
});
/** Source-level guards for two product-wide rules (DESIGN §2.1 and §21.1). */
describe("product rules in the browser source", () => {
function sources(directory: string): string[] {
return readdirSync(directory).flatMap((entry) => {
const path = join(directory, entry);
if (statSync(path).isDirectory()) {
return sources(path);
}
return /\.(ts|tsx)$/u.test(entry) ? [path] : [];
});
}
const files = ["src/ui", "src/browser", "src/display"].flatMap(sources);
it("never uses a browser-native alert, confirm, or prompt", () => {
for (const file of files) {
expect(readFileSync(file, "utf8"), file).not.toMatch(
/\b(?:window\.|globalThis\.)?(?:alert|confirm|prompt)\s*\(/u,
);
}
});
it("never puts the platform's name in user-facing text", () => {
for (const file of files) {
const code = readFileSync(file, "utf8")
.replace(/\/\*[\s\S]*?\*\//gu, "")
.replace(/^\s*\/\/.*$/gmu, "")
.replace(/\{\/\*[\s\S]*?\*\/\}/gu, "");
// Quoted strings and JSX text; identifiers such as `EkklesiaUiStyles` are not text.
expect(code, file).not.toMatch(
/["'`][^"'`\n]*\bEkklesia\b[^"'`\n]*["'`]|>[^<{]*\bEkklesia\b/u,
);
}
});
});
+103 -113
View File
@@ -1,10 +1,10 @@
import type { import type {
ModuleContributionDescriptor, ModuleContributionDescriptor,
ModuleUiApiClient, ModuleUiApiClient,
ModuleUiContributions, ModuleUiContributions,
ModuleUiHostContext, ModuleUiHostContext,
MountedModuleContribution, MountedModuleContribution,
MountModuleContributionOptions, MountModuleContributionOptions,
} from "@ekklesia/module-ui-sdk"; } from "@ekklesia/module-ui-sdk";
/** /**
@@ -15,132 +15,122 @@ import type {
* simply never match. * simply never match.
*/ */
export interface FakeHostRoute { export interface FakeHostRoute {
readonly method: string; readonly method: string;
readonly path: string | RegExp; readonly path: string | RegExp;
readonly respond: (request: { readonly respond: (request: {
readonly path: string; readonly path: string;
readonly init: RequestInit | undefined; readonly init: RequestInit | undefined;
}) => Response | Promise<Response>; }) => Response | Promise<Response>;
} }
export interface FakeHost { export interface FakeHost {
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
readonly requests: { method: string; path: string }[]; readonly requests: { method: string; path: string }[];
/** Contributions this host published against Signage's renderer capability. */ /** Contributions this host published against Signage's renderer capability. */
readonly mounted: { contributionId: string; input: unknown }[]; readonly mounted: { contributionId: string; input: unknown }[];
} }
export function jsonResponse(body: unknown, status = 200): Response { export function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
status, status,
}); });
} }
export function errorResponse( export function errorResponse(code: string, message: string, status = 400): Response {
code: string, return jsonResponse({ code, message }, status);
message: string,
status = 400,
): Response {
return jsonResponse({ code, message }, status);
} }
export interface FakeHostOptions { export interface FakeHostOptions {
readonly permissions: readonly string[]; readonly permissions: readonly string[];
readonly routes: readonly FakeHostRoute[]; readonly routes: readonly FakeHostRoute[];
readonly contributions?: readonly ModuleContributionDescriptor[]; readonly contributions?: readonly ModuleContributionDescriptor[];
readonly displayUrl?: string; readonly displayUrl?: string;
readonly principal?: ModuleUiHostContext["principal"]; readonly principal?: ModuleUiHostContext["principal"];
readonly mount?: ( readonly mount?: (
options: MountModuleContributionOptions, options: MountModuleContributionOptions,
) => Promise<MountedModuleContribution> | MountedModuleContribution; ) => Promise<MountedModuleContribution> | MountedModuleContribution;
} }
export function createFakeHost(options: FakeHostOptions): FakeHost { export function createFakeHost(options: FakeHostOptions): FakeHost {
const requests: { method: string; path: string }[] = []; const requests: { method: string; path: string }[] = [];
const mounted: { contributionId: string; input: unknown }[] = []; const mounted: { contributionId: string; input: unknown }[] = [];
const api: ModuleUiApiClient = { const api: ModuleUiApiClient = {
async request(path, init) { async request(path, init) {
const method = (init?.method ?? "GET").toUpperCase(); const method = (init?.method ?? "GET").toUpperCase();
const normalized = path.replace(/^\//u, ""); const normalized = path.replace(/^\//u, "");
requests.push({ method, path: normalized }); requests.push({ method, path: normalized });
for (const route of options.routes) { for (const route of options.routes) {
const matches = const matches =
typeof route.path === "string" typeof route.path === "string"
? route.path === normalized.split("?")[0] ? route.path === normalized.split("?")[0]
: route.path.test(normalized); : route.path.test(normalized);
if (route.method === method && matches) { if (route.method === method && matches) {
return route.respond({ init, path: normalized }); return route.respond({ init, path: normalized });
} }
} }
return errorResponse( return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
"SIGNAGE_NOT_FOUND", },
"The requested resource was not found.", };
404,
);
},
};
const contributions: ModuleUiContributions = { const contributions: ModuleUiContributions = {
list: (capabilityId) => list: (capabilityId) =>
capabilityId === "signage.slide-renderer" capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
? (options.contributions ?? []) async mount(mountOptions) {
: [], mounted.push({
async mount(mountOptions) { contributionId: mountOptions.contributionId,
mounted.push({ input: mountOptions.input,
contributionId: mountOptions.contributionId, });
input: mountOptions.input, if (options.mount) {
}); return options.mount(mountOptions);
if (options.mount) { }
return options.mount(mountOptions); return {
} contributionId: mountOptions.contributionId,
return { mounted: true,
contributionId: mountOptions.contributionId, unmount: () => Promise.resolve(),
mounted: true, update: () => Promise.resolve(),
unmount: () => Promise.resolve(), version: "1.0.0",
update: () => Promise.resolve(), };
version: "1.0.0", },
}; };
},
};
return { return {
context: { context: {
api, api,
contributions, contributions,
display: { display: {
url: options.displayUrl ?? "https://api.example.test/display/signage", url: options.displayUrl ?? "https://api.example.test/display/signage",
}, },
moduleId: "signage", moduleId: "signage",
permissions: { permissions: {
has: (permission) => options.permissions.includes(permission), has: (permission) => options.permissions.includes(permission),
}, },
principal: options.principal ?? { kind: "anonymous" }, principal: options.principal ?? { kind: "anonymous" },
realtime: { subscribe: () => ({ close: () => undefined }) }, realtime: { subscribe: () => ({ close: () => undefined }) },
}, },
mounted, mounted,
requests, requests,
}; };
} }
/** The settings payload every screen loads first. */ /** The settings payload every screen loads first. */
export const FAKE_SETTINGS = Object.freeze({ export const FAKE_SETTINGS = Object.freeze({
crossfadeMs: 1000, crossfadeMs: 1000,
moduleVersion: "0.1.4", moduleVersion: "0.1.4",
nativeImageDurationMs: 7000, nativeImageDurationMs: 7000,
nativePresentation: { nativePresentation: {
backgroundColor: "#000000", backgroundColor: "#000000",
backgroundDarkened: false, backgroundDarkened: false,
backgroundMode: "blurred", backgroundMode: "blurred",
blurPixels: 20, blurPixels: 20,
}, },
rendererInputVersion: "1.0.0", rendererInputVersion: "1.0.0",
timezone: "America/Chicago", timezone: "America/Chicago",
uploads: { uploads: {
batchMaxFiles: 20, batchMaxFiles: 20,
concurrency: 3, concurrency: 3,
maxBytes: 26_214_400, maxBytes: 26_214_400,
maxPixels: 40_000_000, maxPixels: 40_000_000,
}, },
}); });
+2 -1
View File
@@ -65,7 +65,8 @@ function constraintDatabase(existing: readonly { id: string; position: number }[
}; };
return { return {
database: { database: {
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction), transaction: async (operation: (value: typeof transaction) => Promise<unknown>) =>
operation(transaction),
} as unknown as ModuleDrizzleDatabase, } as unknown as ModuleDrizzleDatabase,
positions, positions,
}; };