10 Commits
17 changed files with 3277 additions and 2169 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ Ekklesia and vendors none of its SDKs.
## Status ## Status
Version 0.1.0 is feature-complete for its declared scope and unreleased. What exists today: Version 0.1.1 is feature-complete for its declared scope and unreleased. What exists today:
- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and - The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
one logical storage bucket. one logical storage bucket.
+1 -1
View File
@@ -3,7 +3,7 @@
"id": "signage", "id": "signage",
"name": "Signage", "name": "Signage",
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.", "description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
"version": "0.1.0", "version": "0.1.1",
"compatibility": { "compatibility": {
"platform": "^0.1.0", "platform": "^0.1.0",
"moduleSdk": "^0.1.0" "moduleSdk": "^0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/signage", "name": "@ekklesia/signage",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.", "description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.",
+1 -1
View File
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "signage", id: "signage",
version: "0.1.0", version: "0.1.1",
styles: displayStyles, styles: displayStyles,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
+2 -2
View File
@@ -126,7 +126,7 @@ export function createSignageModuleRuntime(
const definition = defineModuleRuntime({ const definition = defineModuleRuntime({
id: "signage", id: "signage",
version: "0.1.0", version: "0.1.1",
register({ capabilities, config, http, realtime }) { register({ capabilities, config, http, realtime }) {
const settings = readSignageSettings(config); const settings = readSignageSettings(config);
@@ -188,7 +188,7 @@ export function createSignageModuleRuntime(
// server enforces — a UI believing a stale limit would offer an upload the server refuses. // server enforces — a UI believing a stale limit would offer an upload the server refuses.
return Response.json({ return Response.json({
crossfadeMs: current.crossfadeMs, crossfadeMs: current.crossfadeMs,
moduleVersion: "0.1.0", moduleVersion: "0.1.1",
nativeImageDurationMs: current.nativeImageDurationMs, nativeImageDurationMs: current.nativeImageDurationMs,
nativePresentation: current.nativePresentation, nativePresentation: current.nativePresentation,
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION, rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
+6 -5
View File
@@ -112,20 +112,21 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
/** /**
* Rewrites `position` for one timeline from an explicit order. * Rewrites `position` for one timeline from an explicit order.
* *
* Positions are written in two passes, through a temporary negative range, because the unique * Positions are written in two passes, through a temporary high range, because the unique
* `(timeline_id, position)` index is checked per statement: moving a slot from 3 to 1 while * `(timeline_id, position)` index is checked per statement: moving a slot from 3 to 1 while
* another still holds 1 would collide. Negative positions are outside every real value, so the * another still holds 1 would collide. Temporary positions sit beyond the final order while
* intermediate state cannot be mistaken for an order. * remaining inside the database's non-negative constraint.
*/ */
async function renumber( async function renumber(
transaction: ModuleDatabase, transaction: ModuleDatabase,
timelineId: string, timelineId: string,
orderedSlotIds: readonly string[], orderedSlotIds: readonly string[],
): Promise<void> { ): Promise<void> {
const temporaryOffset = orderedSlotIds.length + 1;
for (const [index, slotId] of orderedSlotIds.entries()) { for (const [index, slotId] of orderedSlotIds.entries()) {
await transaction await transaction
.update(timelineSlots) .update(timelineSlots)
.set({ position: -1 - index }) .set({ position: temporaryOffset + index })
.where(eq(timelineSlots.id, slotId)); .where(eq(timelineSlots.id, slotId));
} }
for (const [index, slotId] of orderedSlotIds.entries()) { for (const [index, slotId] of orderedSlotIds.entries()) {
@@ -175,7 +176,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
position === undefined position === undefined
? existing.length ? existing.length
: Math.max(0, Math.min(position, existing.length)); : Math.max(0, Math.min(position, existing.length));
await transaction.insert(timelineSlots).values(slotValues(slot, -1_000_000)); await transaction.insert(timelineSlots).values(slotValues(slot, existing.length + 1));
const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)]; const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)];
await renumber(transaction, slot.timelineId, next); await renumber(transaction, slot.timelineId, next);
}); });
+165 -129
View File
@@ -1,5 +1,5 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EkklesiaUiStyles, EmptyState, Tabs } from "@ekklesia/ui"; import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui";
import { type ReactElement, useMemo, useState } from "react"; import { type ReactElement, 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";
@@ -23,138 +23,174 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
* 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({
context, context,
copyText,
}: { }: {
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
readonly copyText: (value: string) => Promise<void>;
}): ReactElement { }): ReactElement {
const api = useMemo(() => createSignageApi(context.api), [context.api]); const api = useMemo(() => createSignageApi(context.api), [context.api]);
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing. // 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 // 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. // request it will certainly refuse is noise in a log rather than security.
const mayView = context.permissions.has("signage.view"); const mayView = context.permissions.has("signage.view");
const settings = useResource( const settings = useResource(
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)), () =>
[mayView], mayView
); ? api.get<SettingsResponse>("settings")
const groups = useResource( : Promise.resolve(undefined),
() => [mayView],
mayView );
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups") const groups = useResource(
: Promise.resolve(undefined), () =>
[mayView], mayView
); ? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
const [tab, setTab] = useState("playback"); : Promise.resolve(undefined),
const [groupId, setGroupId] = useState<string | undefined>(undefined); [mayView],
);
const [tab, setTab] = useState("playback");
const [groupId, setGroupId] = useState<string | undefined>(undefined);
const mayManageAssets = context.permissions.has("signage.assets.manage"); const mayManageAssets = context.permissions.has("signage.assets.manage");
const mayManageTimelines = context.permissions.has("signage.timelines.manage"); const mayManageTimelines = context.permissions.has(
const mayControl = context.permissions.has("signage.playback.control"); "signage.timelines.manage",
const mayManageDisplays = context.permissions.has("signage.displays.manage"); );
const mayControl = context.permissions.has("signage.playback.control");
const mayManageDisplays = context.permissions.has("signage.displays.manage");
if (!mayView) { if (!mayView) {
return ( return (
<EmptyState <EmptyState
description="Ask an administrator for the signage.view permission." description="Ask an administrator for the signage.view permission."
title="No access to Signage" title="No access to Signage"
/> />
); );
} }
const selectedGroupId = const selectedGroupId =
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined); groupId ??
const selectedGroup = (groups.state.status === "ready"
groups.state.status === "ready" ? groups.state.value?.groups[0]?.id
? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId) : undefined);
: undefined; const selectedGroup =
groups.state.status === "ready"
? groups.state.value?.groups.find(
(candidate) => candidate.id === selectedGroupId,
)
: undefined;
return ( return (
<> <>
<EkklesiaUiStyles /> <EkklesiaUiStyles />
<DesktopOnly> <DesktopOnly>
{renderLoadable( <PageHeader
settings.state, description="Plan visual content, operate playback, and keep every screen connected."
(loaded) => eyebrow="Media"
loaded === undefined ? undefined : ( title="Signage"
<Tabs />
label="Signage sections" {renderLoadable(
onSelect={setTab} settings.state,
selectedId={tab} (loaded) =>
tabs={[ loaded === undefined ? undefined : (
{ <div className="signage-workspace">
content: ( <Tabs
<PlaybackPanel label="Signage sections"
api={api} onSelect={setTab}
canControl={mayControl} selectedId={tab}
context={context} tabs={[
groupId={selectedGroupId} {
onGroupChange={setGroupId} content: (
settings={loaded} <PlaybackPanel
/> api={api}
), canControl={mayControl}
id: "playback", context={context}
label: "Playback", groupId={selectedGroupId}
}, onGroupChange={setGroupId}
{ settings={loaded}
content: ( />
<TimelinesPanel ),
{...(selectedGroup id: "playback",
? { label: "Playback",
activeGroupName: selectedGroup.name, },
activeTimelineId: selectedGroup.playback.activeTimelineId, {
} content: (
: {})} <TimelinesPanel
api={api} {...(selectedGroup
canManage={mayManageTimelines} ? {
onActivate={ activeGroupName: selectedGroup.name,
mayControl && selectedGroupId !== undefined activeTimelineId:
? async (timelineId) => { selectedGroup.playback.activeTimelineId,
await api.send("POST", `playback/${selectedGroupId}/activate`, { }
timelineId, : {})}
when: "now", api={api}
}); canManage={mayManageTimelines}
groups.reload(); onActivate={
setTab("playback"); mayControl && selectedGroupId !== undefined
} ? async (timelineId) => {
: undefined await api.send(
} "POST",
settings={loaded} `playback/${selectedGroupId}/activate`,
/> {
), timelineId,
id: "timelines", when: "now",
label: "Timelines", },
}, );
{ groups.reload();
content: ( setTab("playback");
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} /> }
), : undefined
id: "images", }
label: "Images", settings={loaded}
}, />
{ ),
content: ( id: "timelines",
<DisplaysPanel label: "Timelines",
api={api} },
canManage={mayManageDisplays} {
onGroupsChanged={() => { content: (
setGroupId(undefined); <ImagesPanel
groups.reload(); api={api}
}} canManage={mayManageAssets}
/> settings={loaded}
), />
id: "displays", ),
label: "Displays", id: "images",
}, label: "Images",
{ },
content: <ProvidersPanel api={api} context={context} settings={loaded} />, {
id: "providers", content: (
label: "Providers", <DisplaysPanel
}, api={api}
]} canManage={mayManageDisplays}
/> copyText={copyText}
), displaySetupUrl={context.display?.url}
{ label: "Loading Signage…", onRetry: settings.reload }, onGroupsChanged={() => {
)} setGroupId(undefined);
</DesktopOnly> groups.reload();
</> }}
); />
),
id: "displays",
label: "Displays",
},
{
content: (
<ProvidersPanel
api={api}
context={context}
settings={loaded}
/>
),
id: "providers",
label: "Providers",
},
]}
/>
</div>
),
{ label: "Loading Signage…", onRetry: settings.reload },
)}
</DesktopOnly>
</>
);
} }
+20 -8
View File
@@ -14,12 +14,24 @@ 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.0", version: "0.1.1",
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(<SignageDashboard context={context} />); root.render(
return () => root.unmount(); <SignageDashboard
}, context={context}
copyText={async (value) => {
if (globalThis.navigator.clipboard === undefined) {
throw new Error(
"Clipboard access is not available in this browser.",
);
}
await globalThis.navigator.clipboard.writeText(value);
}}
/>,
);
return () => root.unmount();
},
}); });
+544 -334
View File
@@ -1,352 +1,562 @@
import { import {
Badge, Badge,
Button, Button,
EmptyState, Field,
Field, InlineNotice,
Panel, Panel,
Select, Select,
Table, TextInput,
TableBody,
TableCell,
TableHead,
TableRow,
TextInput,
} from "@ekklesia/ui"; } from "@ekklesia/ui";
import { type ReactElement, useState } from "react"; 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 { 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; const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
function DisplayRow({
canManage,
groups,
nowMs,
onChannelChange,
onNameChange,
viewer,
}: {
readonly canManage: boolean;
readonly groups: readonly ChannelGroupView[];
readonly nowMs: number;
readonly onChannelChange: (channelId: string) => void;
readonly onNameChange: (name: string) => void;
readonly viewer: ViewerHealthView;
}): ReactElement {
return (
<div
className="signage-display-row"
data-testid={`signage-display-${viewer.id}`}
>
<div className="signage-display-row__identity">
{canManage ? (
<TextInput
aria-label={`Name for ${viewer.name}`}
defaultValue={viewer.name}
onBlur={(event) =>
event.target.value !== viewer.name &&
onNameChange(event.target.value)
}
/>
) : (
<strong>{viewer.name}</strong>
)}
<OnlineBadge online={viewer.online} />
</div>
<div className="signage-display-row__assignment">
{canManage ? (
<Field label="Audience channel">
{(control) => (
<Select
{...control}
aria-label={`Channel for ${viewer.name}`}
onChange={(event) => onChannelChange(event.target.value)}
value={viewer.channelId ?? ""}
>
{groups.map((group) => (
<optgroup key={group.id} label={group.name}>
{group.channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</optgroup>
))}
</Select>
)}
</Field>
) : undefined}
</div>
<div className="signage-display-row__health">
<span>{`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`}</span>
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
{viewer.offlineReady
? `${viewer.cachedAssetCount} cached`
: "Not offline ready"}
</Badge>
{viewer.lastError === null ? undefined : (
<span
className="signage-display-row__error"
title={viewer.lastError.detail ?? undefined}
>
{viewer.lastError.code}
</span>
)}
</div>
</div>
);
}
/** /**
* Channel groups, audience channels, and the display roster. * Channel groups, audience channels, and displays, presented in the same hierarchy they use at run
* * time: one group clock, one audience variant per channel, then the screens assigned to it.
* A church starts with one group and one default channel. `in-person` and `online` are created here,
* when someone actually needs them — not shipped as rows nobody asked for and everybody has to
* understand and ignore (ADR 0004).
*/ */
export function DisplaysPanel({ export function DisplaysPanel({
api, api,
canManage, canManage,
onGroupsChanged, copyText,
displaySetupUrl,
onGroupsChanged,
}: { }: {
readonly api: SignageApi; readonly api: SignageApi;
readonly canManage: boolean; readonly canManage: boolean;
readonly onGroupsChanged?: () => void; readonly copyText: (value: string) => Promise<void>;
readonly displaySetupUrl: string | undefined;
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"),
[], [],
); );
const viewers = useResource( const viewers = useResource(
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), () => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
[], [],
); );
const edit = useAction(() => { const edit = useAction(() => {
groups.reload(); groups.reload();
viewers.reload(); viewers.reload();
onGroupsChanged?.(); onGroupsChanged?.();
}); });
const viewerEdit = useAction(viewers.reload); const viewerEdit = useAction(viewers.reload);
const [groupName, setGroupName] = useState(""); const [groupName, setGroupName] = useState("");
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined); const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">(
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({ "idle",
groupId: "", );
key: "", const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(
name: "", undefined,
}); );
// "Last seen" is a moving number: without a tick it would freeze at whatever it said when the const [channelDraft, setChannelDraft] = useState<{
// roster loaded, and an operator would read a screen that went dark ten minutes ago as current. groupId: string;
useTick(5000); key: string;
const nowMs = Date.now(); name: string;
}>({
groupId: "",
key: "",
name: "",
});
useTick(5000);
const nowMs = Date.now();
const readyViewers =
viewers.state.status === "ready" ? viewers.state.value.viewers : [];
const channels = const updateViewer = (
groups.state.status === "ready" viewerId: string,
? groups.state.value.groups.flatMap((group) => changes: Record<string, string>,
group.channels.map((channel) => ({ ...channel, groupName: group.name })), ): void => {
) viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
: []; };
return ( return (
<div className="signage-columns"> <div className="signage-columns">
<Panel <Panel
actions={ actions={
canManage ? ( displaySetupUrl === undefined ? undefined : (
<div className="signage-inline-form"> <div className="signage-pairing-actions">
<Field label="New channel group"> <a
{(control) => ( className="ek-button ek-focusable ek-button--primary"
<TextInput href={displaySetupUrl}
{...control} rel="noopener noreferrer"
onChange={(event) => setGroupName(event.target.value)} target="_blank"
placeholder="Nursery hallway" >
value={groupName} Open display setup
/> </a>
)} <Button
</Field> onClick={() => {
<Button setCopyStatus("idle");
disabled={edit.pending || groupName.trim().length === 0} void copyText(displaySetupUrl).then(
onClick={() => () => setCopyStatus("copied"),
edit.run(async () => { () => setCopyStatus("failed"),
await api.send("POST", "channel-groups", { name: groupName.trim() }); );
setGroupName(""); }}
}) >
} Copy URL
variant="primary" </Button>
> </div>
Create )
</Button> }
</div> description="Connect a screen in three short steps."
) : undefined title="Pair a display"
} >
description="A group is one clock. An audience channel is one rendering of it." <ol
title="Channel groups" className="signage-pairing-steps"
> aria-label="Display pairing steps"
{renderLoadable( >
groups.state, <li>
(value) => ( <span className="signage-pairing-steps__number">1</span>
<div className="signage-groups" data-testid="signage-groups"> <span>
{value.groups.map((group) => ( Open display setup on the screen that will show signage.
<div className="signage-group" key={group.id}> </span>
<div className="signage-group__header"> </li>
<h4 className="signage-editor__subtitle">{group.name}</h4> <li>
<Badge tone="info">{group.playback.transport}</Badge> <span className="signage-pairing-steps__number">2</span>
{canManage && value.groups.length > 1 ? ( <span>
<Button Enter its six-digit code under Display devices in the Dashboard
disabled={edit.pending} sidebar.
onClick={() => setConfirmGroupId(group.id)} </span>
variant="danger" </li>
> <li>
Delete group <span className="signage-pairing-steps__number">3</span>
</Button> <span>Name the display and choose its audience channel below.</span>
) : canManage ? ( </li>
<Badge tone="neutral">At least one group required</Badge> </ol>
) : undefined} {displaySetupUrl === undefined ? (
</div> <InlineNotice tone="warning" title="Display setup unavailable">
{confirmGroupId === group.id ? ( This host did not provide a display setup address.
<div className="signage-confirm" role="alert"> </InlineNotice>
<p> ) : undefined}
Delete {group.name}? Its playback history will be removed. Displays assigned <span className="signage-copy-status" aria-live="polite">
here will move to the default channel of another group. {copyStatus === "copied"
</p> ? "Display setup URL copied."
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}> : copyStatus === "failed"
Cancel ? "Could not copy the URL. Copy it from the newly opened setup page."
</Button> : ""}
<Button </span>
disabled={edit.pending} {viewers.state.status === "ready" &&
onClick={() => viewers.state.value.viewers.length === 0 ? (
edit.run(async () => { <div className="signage-display-empty">
await api.send("DELETE", `channel-groups/${group.id}`); <strong>No displays paired yet</strong>
setConfirmGroupId(undefined); <span>
}) A newly approved screen appears below in its assigned channel.
} </span>
variant="danger" </div>
> ) : undefined}
Confirm delete </Panel>
</Button>
</div>
) : undefined}
<ul className="ek-list">
{group.channels.map((channel) => (
<li className="ek-list__item signage-row" key={channel.id}>
<span className="signage-row__name">{channel.name}</span>
<Badge tone={channel.isDefault ? "success" : "neutral"}>
{channel.key}
</Badge>
{canManage && !channel.isDefault ? (
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
}
variant="danger"
>
Remove
</Button>
) : undefined}
</li>
))}
</ul>
{canManage ? (
<div className="signage-inline-form" data-testid="signage-add-channel">
<Field
hint="Stable lowercase ID sent to content providers for audience-specific variants, e.g. online."
label="Provider audience key"
>
{(control) => (
<TextInput
{...control}
maxLength={64}
onChange={(event) =>
setChannelDraft((current) => ({
groupId: group.id,
key: event.target.value,
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 : ""}
/>
)}
</Field>
<Field
hint="Friendly label shown to administrators and used when assigning displays."
label="Dashboard channel name"
>
{(control) => (
<TextInput
{...control}
maxLength={120}
onChange={(event) =>
setChannelDraft((current) => ({
groupId: group.id,
key: current.groupId === group.id ? current.key : "",
name: event.target.value,
}))
}
placeholder="Online audience"
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
/>
)}
</Field>
<Button
disabled={
edit.pending ||
channelDraft.groupId !== group.id ||
!AUDIENCE_KEY.test(channelDraft.key.trim()) ||
channelDraft.key.trim() === "default" ||
channelDraft.name.trim() === ""
}
onClick={() =>
edit.run(async () => {
await api.send("POST", `channel-groups/${group.id}/channels`, {
key: channelDraft.key.trim(),
name: channelDraft.name.trim(),
});
setChannelDraft({ groupId: "", key: "", name: "" });
})
}
>
Add audience channel
</Button>
</div>
) : undefined}
</div>
))}
</div>
),
{ label: "Loading channel groups…", onRetry: groups.reload },
)}
</Panel>
<Panel <Panel
description="Pair a screen through Ekklesia, then name it and choose which audience channel it shows." actions={
title="Displays" canManage ? (
> <div className="signage-inline-form signage-new-group">
{canManage ? ( <Field label="New channel group">
<div className="signage-registration-guide"> {(control) => (
<h4 className="signage-editor__subtitle">Register a display</h4> <TextInput
<ol> {...control}
<li>Open the Ekklesia path /display/signage on the screen.</li> onChange={(event) => setGroupName(event.target.value)}
<li>Enter its six-digit code under Display devices in the Dashboard sidebar.</li> placeholder="Nursery hallway"
<li>When it appears below, give it a clear name and assign its channel.</li> value={groupName}
</ol> />
</div> )}
) : undefined} </Field>
{renderLoadable( <Button
viewers.state, disabled={edit.pending || groupName.trim().length === 0}
(value) => onClick={() =>
value.viewers.length === 0 ? ( edit.run(async () => {
<EmptyState await api.send("POST", "channel-groups", {
description="Open /display/signage on the screen, approve its code under Display devices, and it will appear here automatically." name: groupName.trim(),
title="No displays yet" });
/> setGroupName("");
) : ( })
<Table label="Display health"> }
<TableHead> variant="primary"
<TableRow> >
<TableCell header>Name</TableCell> Create group
<TableCell header>State</TableCell> </Button>
<TableCell header>Channel</TableCell> </div>
<TableCell header>Last seen</TableCell> ) : undefined
<TableCell header>Offline ready</TableCell> }
<TableCell header>Most recent error</TableCell> description="A channel group shares one playback clock. Each audience channel can receive a different content variant."
</TableRow> title="Channel groups and displays"
</TableHead> >
<TableBody> {renderLoadable(
{value.viewers.map((viewer) => ( groups.state,
<TableRow key={viewer.id}> (value) => {
<TableCell> const knownChannelIds = new Set(
{canManage ? ( value.groups.flatMap((group) =>
<TextInput group.channels.map((channel) => channel.id),
aria-label={`Name for ${viewer.name}`} ),
defaultValue={viewer.name} );
onBlur={(event) => const unassigned = readyViewers.filter(
event.target.value !== viewer.name && (viewer) =>
viewerEdit.run(() => viewer.channelId === null ||
api.send("PATCH", `viewers/${viewer.id}`, { !knownChannelIds.has(viewer.channelId),
name: event.target.value, );
}), return (
) <div className="signage-groups" data-testid="signage-groups">
} {value.groups.map((group) => (
/> <article className="signage-group" key={group.id}>
) : ( <header className="signage-group__header">
viewer.name <div className="signage-group__identity">
)} <span className="signage-hierarchy-label">
</TableCell> Channel group
<TableCell> </span>
<OnlineBadge online={viewer.online} /> <h3>{group.name}</h3>
</TableCell> </div>
<TableCell> <Badge tone="info">{group.playback.transport}</Badge>
{canManage ? ( {canManage && value.groups.length > 1 ? (
<Select <Button
aria-label={`Channel for ${viewer.name}`} disabled={edit.pending}
onChange={(event) => onClick={() => setConfirmGroupId(group.id)}
viewerEdit.run(() => variant="danger"
api.send("PATCH", `viewers/${viewer.id}`, { >
channelId: event.target.value, Delete group
}), </Button>
) ) : canManage ? (
} <Badge tone="neutral">Required group</Badge>
value={viewer.channelId ?? ""} ) : undefined}
> </header>
{channels.map((channel) => ( {confirmGroupId === group.id ? (
<option key={channel.id} value={channel.id}> <div className="signage-confirm" role="alert">
{`${channel.groupName} · ${channel.name}`} <p>
</option> Delete {group.name}? Its playback history will be
))} removed. Displays assigned here will move to the
</Select> default channel of another group.
) : ( </p>
(channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—") <Button
)} disabled={edit.pending}
</TableCell> onClick={() => setConfirmGroupId(undefined)}
<TableCell>{formatSince(viewer.lastSeenAtMs, nowMs)}</TableCell> >
<TableCell> Cancel
<Badge tone={viewer.offlineReady ? "success" : "neutral"}> </Button>
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"} <Button
</Badge> disabled={edit.pending}
</TableCell> onClick={() =>
<TableCell> edit.run(async () => {
{viewer.lastError === null ? ( await api.send(
"—" "DELETE",
) : ( `channel-groups/${group.id}`,
<span title={viewer.lastError.detail ?? undefined}> );
{viewer.lastError.code} setConfirmGroupId(undefined);
</span> })
)} }
</TableCell> variant="danger"
</TableRow> >
))} Confirm delete
</TableBody> </Button>
</Table> </div>
), ) : undefined}
{ label: "Loading displays…", onRetry: viewers.reload },
)} <section className="signage-audiences">
<FailureNote failure={edit.failure} /> <div className="signage-audiences__heading">
<FailureNote failure={viewerEdit.failure} /> <div>
</Panel> <span className="signage-hierarchy-label">
</div> Audience channels
); </span>
<h4>Who this group renders for</h4>
</div>
<span>
Displays inherit the channel they are assigned to.
</span>
</div>
<div className="signage-channel-list">
{group.channels.map((channel) => {
const assigned = readyViewers.filter(
(viewer) => viewer.channelId === channel.id,
);
return (
<article
className="signage-channel"
key={channel.id}
>
<header className="signage-channel__header">
<div>
<h5>{channel.name}</h5>
<div className="signage-channel__meta">
{channel.isDefault ? (
<Badge tone="success">
Default channel
</Badge>
) : undefined}
<span>Audience key</span>
<code>{channel.key}</code>
<Badge tone="neutral">
{`${assigned.length} display${assigned.length === 1 ? "" : "s"}`}
</Badge>
</div>
</div>
{canManage && !channel.isDefault ? (
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send(
"DELETE",
`channels/${channel.id}`,
),
)
}
variant="danger"
>
Remove channel
</Button>
) : undefined}
</header>
<div className="signage-channel__displays">
<span className="signage-hierarchy-label">
Displays
</span>
{assigned.length === 0 ? (
<p className="signage-channel__empty">
No displays assigned.
</p>
) : (
assigned.map((viewer) => (
<DisplayRow
canManage={canManage}
groups={value.groups}
key={viewer.id}
nowMs={nowMs}
onChannelChange={(channelId) =>
updateViewer(viewer.id, { channelId })
}
onNameChange={(name) =>
updateViewer(viewer.id, { name })
}
viewer={viewer}
/>
))
)}
</div>
</article>
);
})}
</div>
{canManage ? (
<div
className="signage-channel-form"
data-testid="signage-add-channel"
>
<div className="signage-channel-form__heading">
<h5>Add an audience channel</h5>
<span>
The default channel always remains available.
</span>
</div>
<Field
hint="Shown to administrators and when assigning displays."
label="Channel name"
>
{(control) => (
<TextInput
{...control}
maxLength={120}
onChange={(event) =>
setChannelDraft((current) => ({
groupId: group.id,
key:
current.groupId === group.id
? current.key
: "",
name: event.target.value,
}))
}
placeholder="Online audience"
value={
channelDraft.groupId === group.id
? channelDraft.name
: ""
}
/>
)}
</Field>
<Field
hint="Advanced: lowercase ID sent to content providers, e.g. online."
label="Audience key"
>
{(control) => (
<TextInput
{...control}
maxLength={64}
onChange={(event) =>
setChannelDraft((current) => ({
groupId: group.id,
key: event.target.value,
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
: ""
}
/>
)}
</Field>
<Button
disabled={
edit.pending ||
channelDraft.groupId !== group.id ||
!AUDIENCE_KEY.test(channelDraft.key.trim()) ||
channelDraft.key.trim() === "default" ||
channelDraft.name.trim() === ""
}
onClick={() =>
edit.run(async () => {
await api.send(
"POST",
`channel-groups/${group.id}/channels`,
{
key: channelDraft.key.trim(),
name: channelDraft.name.trim(),
},
);
setChannelDraft({
groupId: "",
key: "",
name: "",
});
})
}
variant="primary"
>
Add channel
</Button>
</div>
) : undefined}
</section>
</article>
))}
{unassigned.length === 0 ? undefined : (
<section className="signage-unassigned">
<span className="signage-hierarchy-label">
Needs assignment
</span>
{unassigned.map((viewer) => (
<DisplayRow
canManage={canManage}
groups={value.groups}
key={viewer.id}
nowMs={nowMs}
onChannelChange={(channelId) =>
updateViewer(viewer.id, { channelId })
}
onNameChange={(name) =>
updateViewer(viewer.id, { name })
}
viewer={viewer}
/>
))}
</section>
)}
</div>
);
},
{ label: "Loading channel groups…", onRetry: groups.reload },
)}
{renderLoadable(viewers.state, () => null, {
label: "Loading displays…",
onRetry: viewers.reload,
})}
<FailureNote failure={edit.failure} />
<FailureNote failure={viewerEdit.failure} />
</Panel>
</div>
);
} }
+330 -262
View File
@@ -1,16 +1,29 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui"; import {
ActionBar,
Badge,
Button,
EmptyState,
Field,
Panel,
Select,
} from "@ekklesia/ui";
import { type ReactElement, useState } from "react"; 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 { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js"; import {
FailureNote,
formatDuration,
formatInstant,
renderLoadable,
} from "./shared.js";
/** /**
* Live transport for one channel group. * Live transport for one channel group.
@@ -20,270 +33,325 @@ import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./sh
* 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"),
[], [],
); );
const timelines = useResource( const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
[], [],
); );
const detail = useResource( const detail = useResource(
() => () =>
groupId === undefined groupId === undefined
? Promise.resolve(undefined) ? Promise.resolve(undefined)
: api.get<PlaybackDetailView>(`playback/${groupId}`), : api.get<PlaybackDetailView>(`playback/${groupId}`),
[groupId], [groupId],
); );
const command = useAction(() => { const command = useAction(() => {
detail.reload(); detail.reload();
groups.reload(); groups.reload();
}); });
const [activateId, setActivateId] = useState(""); const [activateId, setActivateId] = useState("");
const previewChannelId = const previewChannelId =
groups.state.status === "ready" groups.state.status === "ready"
? (groups.state.value.groups ? (groups.state.value.groups
.find((group) => group.id === groupId) .find((group) => group.id === groupId)
?.channels.find((channel) => channel.isDefault)?.id ?? undefined) ?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
: undefined; : undefined;
// A one-second tick so the remaining time counts down rather than sitting still between refreshes. // A one-second tick so the remaining time counts down rather than sitting still between refreshes.
useTick(1000); useTick(1000);
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((timeline) => timeline.id === timelineId)?.name ?? timelines.state.value.timelines.find(
timelineId (timeline) => timeline.id === timelineId,
); )?.name ?? timelineId
} );
}
return ( return (
<div className="signage-columns"> <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 className="signage-playback" data-testid="signage-playback"> <div
<div className="signage-playback__status"> className="signage-playback"
{value.snapshot.activeTimelineId === null ? undefined : ( data-testid="signage-playback"
<Badge tone="success"> >
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`} <div className="signage-playback__status">
</Badge> {value.snapshot.activeTimelineId === null ? undefined : (
)} <Badge tone="success">
<Badge {`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
tone={ </Badge>
value.snapshot.transport === "playing" )}
? "success" <Badge
: value.snapshot.transport === "idle" tone={
? "warning" value.snapshot.transport === "playing"
: "info" ? "success"
} : value.snapshot.transport === "idle"
> ? "warning"
{value.snapshot.transport} : "info"
</Badge> }
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined} >
{value.current?.stale === true ? ( {value.snapshot.transport}
<Badge tone="warning">Stale</Badge> </Badge>
) : undefined} {value.snapshot.blackout ? (
{value.current?.withdrawn === true ? ( <Badge tone="danger">Blackout</Badge>
<Badge tone="warning">Withdrawn</Badge> ) : undefined}
) : undefined} {value.current?.stale === true ? (
{value.snapshot.pendingTimelineId === null ? undefined : ( <Badge tone="warning">Stale</Badge>
<Badge tone="info">Activation queued</Badge> ) : undefined}
)} {value.current?.withdrawn === true ? (
</div> <Badge tone="warning">Withdrawn</Badge>
) : 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"
/> />
) : ( ) : (
<dl className="signage-playback__current" data-testid="signage-current"> <section className="signage-playback__now">
<dt>Provider</dt> <h3>Now showing</h3>
<dd>{value.current.providerId}</dd> <dl
<dt>Item</dt> className="signage-playback__current"
<dd>{value.current.itemId}</dd> data-testid="signage-current"
<dt>Renderer</dt> >
<dd>{value.current.rendererContributionId ?? "—"}</dd> <dt>Provider</dt>
<dt>Duration</dt> <dd>{value.current.providerId}</dd>
<dd>{formatDuration(value.current.durationMs)}</dd> <dt>Item</dt>
<dt>Remaining</dt> <dd>{value.current.itemId}</dd>
<dd data-testid="signage-remaining"> <dt>Renderer</dt>
{value.snapshot.remainingMs === null <dd>{value.current.rendererContributionId ?? "—"}</dd>
? "—" <dt>Duration</dt>
: formatDuration(value.snapshot.remainingMs)} <dd>{formatDuration(value.current.durationMs)}</dd>
</dd> <dt>Remaining</dt>
<dt>Started</dt> <dd data-testid="signage-remaining">
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd> {value.snapshot.remainingMs === null
</dl> ? "—"
)} : formatDuration(value.snapshot.remainingMs)}
</dd>
<dt>Started</dt>
<dd>
{formatInstant(
value.current.startedAtMs,
settings.timezone,
)}
</dd>
</dl>
</section>
)}
{canControl ? ( {canControl ? (
<div className="signage-playback__controls" data-testid="signage-controls"> <ActionBar
<Button disabled={command.pending} onClick={() => run("previous")}> className="signage-playback__controls"
Previous data-testid="signage-controls"
</Button> label="Playback controls"
{value.snapshot.transport === "paused" ? ( >
<Button <Button
disabled={command.pending} disabled={command.pending}
onClick={() => run("resume")} onClick={() => run("previous")}
variant="primary" >
> Previous
Resume </Button>
</Button> {value.snapshot.transport === "paused" ? (
) : ( <Button
<Button disabled={command.pending} onClick={() => run("pause")}> disabled={command.pending}
Pause onClick={() => run("resume")}
</Button> variant="primary"
)} >
<Button disabled={command.pending} onClick={() => run("next")}> Resume
Next </Button>
</Button> ) : (
<Button <Button
disabled={command.pending} disabled={command.pending}
onClick={() => run("blackout", { on: !value.snapshot.blackout })} onClick={() => run("pause")}
variant={value.snapshot.blackout ? "primary" : "secondary"} >
> Pause
{value.snapshot.blackout ? "End blackout" : "Blackout"} </Button>
</Button> )}
{value.snapshot.staticAssetId === null ? undefined : ( <Button
<Button disabled={command.pending}
disabled={command.pending} onClick={() => run("next")}
onClick={() => run("static", { assetId: null })} >
> Next
Release hold </Button>
</Button> <Button
)} disabled={command.pending}
<Button disabled={command.pending} onClick={() => run("reload")}> onClick={() =>
Reload content run("blackout", { on: !value.snapshot.blackout })
</Button> }
</div> variant={
) : undefined} value.snapshot.blackout ? "primary" : "secondary"
}
>
{value.snapshot.blackout ? "End blackout" : "Blackout"}
</Button>
{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) => setActivateId(event.target.value)} onChange={(event) =>
value={activateId} setActivateId(event.target.value)
> }
<option value="">Choose a timeline…</option> value={activateId}
{loaded.timelines.map((timeline) => ( >
<option key={timeline.id} value={timeline.id}> <option value="">Choose a timeline…</option>
{timeline.name} {loaded.timelines.map((timeline) => (
</option> <option
))} key={timeline.id}
</Select> value={timeline.id}
)} >
</Field> {timeline.name}
<Button </option>
disabled={command.pending || activateId === ""} ))}
onClick={() => </Select>
run("activate", { timelineId: activateId, when: "now" }) )}
} </Field>
variant="primary" <Button
> disabled={command.pending || activateId === ""}
Now onClick={() =>
</Button> run("activate", {
<Button timelineId: activateId,
disabled={command.pending || activateId === ""} when: "now",
onClick={() => })
run("activate", { timelineId: activateId, when: "next-boundary" }) }
} variant="primary"
> >
At next slot Now
</Button> </Button>
</> <Button
), disabled={command.pending || activateId === ""}
{ label: "Loading timelines…" }, onClick={() =>
)} run("activate", {
</div> timelineId: activateId,
) : undefined} when: "next-boundary",
})
}
>
At next slot
</Button>
</>
),
{ 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">Recently played</h4> <h4 className="signage-editor__subtitle">
<ul className="ek-list"> Recently played
{value.history.slice(0, 8).map((entry) => ( </h4>
<li className="ek-list__item signage-row" key={entry.id}> <ul className="ek-list">
<span className="signage-row__name">{entry.itemId}</span> {value.history.slice(0, 8).map((entry) => (
<span className="signage-playback__history-meta"> <li
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`} className="ek-list__item signage-row"
</span> key={entry.id}
</li> >
))} <span className="signage-row__name">
</ul> {entry.itemId}
</div> </span>
)} <span className="signage-playback__history-meta">
</div> {`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
), </span>
{ label: "Loading playback…", onRetry: detail.reload }, </li>
) ))}
)} </ul>
<FailureNote failure={command.failure} /> </div>
</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>
); );
} }
+102 -87
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,86 +31,101 @@ 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"),
[], [],
); );
const published = new Set( const published = new Set(
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id), context.contributions
); .list(SLIDE_RENDERER_CAPABILITY)
.map((contribution) => contribution.id),
);
return ( return (
<Panel <Panel
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`} actions={
title="Content providers" <Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>
> }
{renderLoadable( description="Running content sources available to timelines."
providers.state, title="Content providers"
(value) => >
value.providers.length === 0 ? ( {renderLoadable(
<EmptyState providers.state,
description="Signage works on its own with native images. Install a content module to add more." (value) =>
title="No content providers registered" value.providers.length === 0 ? (
/> <EmptyState
) : ( description="Signage works on its own with native images. Install a content module to add more."
<Table label="Provider health"> title="No content providers registered"
<TableHead> />
<TableRow> ) : (
<TableCell header>Provider</TableCell> <Table label="Provider health">
<TableCell header>Contract</TableCell> <TableHead>
<TableCell header>Renderer</TableCell> <TableRow>
<TableCell header>Health</TableCell> <TableCell header>Provider</TableCell>
<TableCell header>Last success</TableCell> <TableCell header>Contract</TableCell>
</TableRow> <TableCell header>Renderer</TableCell>
</TableHead> <TableCell header>Health</TableCell>
<TableBody> <TableCell header>Last success</TableCell>
{value.providers.map((provider) => { </TableRow>
const rendererPublished = published.has(provider.renderer.contributionId); </TableHead>
const isNative = provider.renderer.contributionId === "signage.native-image"; <TableBody>
return ( {value.providers.map((provider) => {
<TableRow key={provider.providerId}> const rendererPublished = published.has(
<TableCell> provider.renderer.contributionId,
<span className="signage-provider__id">{provider.providerId}</span> );
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span> const isNative =
</TableCell> provider.renderer.contributionId === "signage.native-image";
<TableCell>{provider.contractVersion}</TableCell> return (
<TableCell> <TableRow key={provider.providerId}>
{isNative ? ( <TableCell>
<Badge tone="success">Signage renders this</Badge> <span className="signage-provider__id">
) : rendererPublished ? ( {provider.providerId}
<Badge tone="success">{provider.renderer.contributionId}</Badge> </span>
) : ( <span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
<Badge tone="warning">Renderer not published</Badge> </TableCell>
)} <TableCell>{provider.contractVersion}</TableCell>
</TableCell> <TableCell>
<TableCell> {isNative ? (
{provider.consecutiveFailures === 0 ? ( <Badge tone="info">Built in</Badge>
<Badge tone="success">Healthy</Badge> ) : rendererPublished ? (
) : ( <Badge tone="success">
<Badge tone="danger"> {provider.renderer.contributionId}
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`} </Badge>
</Badge> ) : (
)} <Badge tone="warning">Renderer not published</Badge>
</TableCell> )}
<TableCell> </TableCell>
{formatInstant(provider.lastSuccessAt, settings.timezone)} <TableCell>
</TableCell> {provider.consecutiveFailures === 0 ? (
</TableRow> <Badge tone="success">Healthy</Badge>
); ) : (
})} <Badge tone="danger">
</TableBody> {`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
</Table> </Badge>
), )}
{ label: "Loading providers…", onRetry: providers.reload }, </TableCell>
)} <TableCell>
</Panel> {formatInstant(
); provider.lastSuccessAt,
settings.timezone,
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
),
{ label: "Loading providers…", onRetry: providers.reload },
)}
</Panel>
);
} }
File diff suppressed because it is too large Load Diff
+554 -262
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+113 -89
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,108 +15,132 @@ 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(code: string, message: string, status = 400): Response { export function errorResponse(
return jsonResponse({ code, message }, status); code: string,
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 principal?: ModuleUiHostContext["principal"]; readonly displayUrl?: string;
readonly mount?: ( readonly principal?: ModuleUiHostContext["principal"];
options: MountModuleContributionOptions, readonly mount?: (
) => Promise<MountedModuleContribution> | MountedModuleContribution; options: MountModuleContributionOptions,
) => 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("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404); return errorResponse(
}, "SIGNAGE_NOT_FOUND",
}; "The requested resource was not found.",
404,
);
},
};
const contributions: ModuleUiContributions = { const contributions: ModuleUiContributions = {
list: (capabilityId) => list: (capabilityId) =>
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [], capabilityId === "signage.slide-renderer"
async mount(mountOptions) { ? (options.contributions ?? [])
mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input }); : [],
if (options.mount) { async mount(mountOptions) {
return options.mount(mountOptions); mounted.push({
} contributionId: mountOptions.contributionId,
return { input: mountOptions.input,
contributionId: mountOptions.contributionId, });
mounted: true, if (options.mount) {
unmount: () => Promise.resolve(), return options.mount(mountOptions);
update: () => Promise.resolve(), }
version: "1.0.0", return {
}; contributionId: mountOptions.contributionId,
}, mounted: true,
}; unmount: () => Promise.resolve(),
update: () => Promise.resolve(),
version: "1.0.0",
};
},
};
return { return {
context: { context: {
api, api,
contributions, contributions,
moduleId: "signage", display: {
permissions: { has: (permission) => options.permissions.includes(permission) }, url: options.displayUrl ?? "https://api.example.test/display/signage",
principal: options.principal ?? { kind: "anonymous" }, },
realtime: { subscribe: () => ({ close: () => undefined }) }, moduleId: "signage",
}, permissions: {
mounted, has: (permission) => options.permissions.includes(permission),
requests, },
}; principal: options.principal ?? { kind: "anonymous" },
realtime: { subscribe: () => ({ close: () => undefined }) },
},
mounted,
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.0", moduleVersion: "0.1.1",
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: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 }, uploads: {
batchMaxFiles: 20,
concurrency: 3,
maxBytes: 26_214_400,
maxPixels: 40_000_000,
},
}); });
+95
View File
@@ -0,0 +1,95 @@
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import { describe, expect, it } from "vitest";
import { createTimelineStore } from "../src/server/store/drizzle-timelines.js";
import type { NewStoredSlot } from "../src/server/store/types.js";
const TIMELINE_ID = "00000000-0000-4000-8000-000000000001";
function slot(id: string): NewStoredSlot {
return {
durationOverrideMs: null,
eligibleFrom: null,
eligibleUntil: null,
enabled: true,
id,
itemId: "image-1",
kind: "pinned-item",
presentation: {
backgroundColor: null,
backgroundDarkened: null,
backgroundMode: null,
blurPixels: null,
foregroundShadow: null,
},
providerId: "signage.native-images",
sourceId: null,
timelineId: TIMELINE_ID,
};
}
/**
* A narrow database contract double for the production timeline store.
*
* Unlike the service-level memory store, it enforces the shipped PostgreSQL
* `signage_timeline_slots_position_check` on every insert and update. That is the boundary the
* deployed bug crossed: the old collision-avoidance algorithm used negative positions, so the
* actual database rejected a native-image slot before playback ever had content to resolve.
*/
function constraintDatabase(existing: readonly { id: string; position: number }[]): {
readonly database: ModuleDatabase;
readonly positions: number[];
} {
const positions: number[] = [];
const enforce = (values: { position?: unknown }): void => {
if (typeof values.position === "number") {
expect(values.position).toBeGreaterThanOrEqual(0);
positions.push(values.position);
}
};
const transaction = {
insert: () => ({
values: async (values: { position?: unknown }) => enforce(values),
}),
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => existing,
}),
}),
}),
update: () => ({
set: (values: { position?: unknown }) => ({
where: async () => enforce(values),
}),
}),
};
return {
database: {
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction),
} as unknown as ModuleDatabase,
positions,
};
}
describe("production timeline slot ordering", () => {
it("creates the first pinned native image without violating the database position constraint", async () => {
const { database, positions } = constraintDatabase([]);
await createTimelineStore(database).addSlot(slot("00000000-0000-4000-8000-000000000002"));
expect(positions).toEqual([1, 2, 0]);
});
it("reorders through a non-negative temporary range before writing the final order", async () => {
const first = "00000000-0000-4000-8000-000000000002";
const second = "00000000-0000-4000-8000-000000000003";
const { database, positions } = constraintDatabase([
{ id: first, position: 0 },
{ id: second, position: 1 },
]);
await createTimelineStore(database).reorderSlots(TIMELINE_ID, [second, first]);
expect(positions).toEqual([3, 4, 0, 1]);
});
});
+3 -3
View File
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
expect(manifest.manifestVersion).toBe(7); expect(manifest.manifestVersion).toBe(7);
expect(manifest.id).toBe("signage"); expect(manifest.id).toBe("signage");
expect(manifest.name).toBe("Signage"); expect(manifest.name).toBe("Signage");
expect(manifest.version).toBe("0.1.0"); expect(manifest.version).toBe("0.1.1");
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" }); expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
}); });
@@ -142,7 +142,7 @@ describe("Signage module manifest", () => {
describe("Signage server runtime", () => { describe("Signage server runtime", () => {
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => { it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.0" }); expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.1" });
}); });
it("registers its routes declaratively, with no database, storage, or realtime work", async () => { it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
@@ -241,7 +241,7 @@ describe("Signage server runtime", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.json()).toEqual({ expect(await response.json()).toEqual({
crossfadeMs: 400, crossfadeMs: 400,
moduleVersion: "0.1.0", moduleVersion: "0.1.1",
nativeImageDurationMs: 12_000, nativeImageDurationMs: 12_000,
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation, nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
rendererInputVersion: "1.0.0", rendererInputVersion: "1.0.0",