@norskvideo/ctl-sdk 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/ShotBar.d.ts +25 -0
- package/components/ShotBar.js +62 -0
- package/components/index.d.ts +2 -0
- package/components/index.js +2 -0
- package/components/shot-bar-view-model.d.ts +24 -0
- package/components/shot-bar-view-model.js +59 -0
- package/package.json +1 -1
- package/product-service.d.ts +21 -0
- package/product-service.js +34 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DerivedShot, ResolvedShot, TakeCommand, Transition } from "@norskvideo/ctl-foundation/browser";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
export interface ShotBarProps {
|
|
4
|
+
/** Shots already derived from the manifest + live room by the consumer. */
|
|
5
|
+
shots: DerivedShot[];
|
|
6
|
+
/** The shot currently on air, if known — highlights its card. */
|
|
7
|
+
pgmShotId?: string | null;
|
|
8
|
+
/** The shot currently in preview, if the product runs a preview bus. */
|
|
9
|
+
pvwShotId?: string | null;
|
|
10
|
+
/** Origin for resolving relative graphic paths. Defaults to `location.origin`. */
|
|
11
|
+
origin?: string;
|
|
12
|
+
/** Default transition applied to takes. */
|
|
13
|
+
transition?: Transition;
|
|
14
|
+
/** Show the editable graphic-URL field on graphic-bearing cards (default true). */
|
|
15
|
+
editableGraphics?: boolean;
|
|
16
|
+
/** Send the assembled commands to air. The consumer owns the WebSocket. */
|
|
17
|
+
onTake: (commands: TakeCommand[], shot: ResolvedShot) => void;
|
|
18
|
+
/** Optional preview bus — omit to hide the Preview control. */
|
|
19
|
+
onPreview?: (commands: TakeCommand[], shot: ResolvedShot) => void;
|
|
20
|
+
/** Drop-in program monitor (e.g. a WHEP player). */
|
|
21
|
+
renderProgramMonitor?: () => ReactNode;
|
|
22
|
+
/** Drop-in preview monitor. */
|
|
23
|
+
renderPreviewMonitor?: () => ReactNode;
|
|
24
|
+
}
|
|
25
|
+
export declare function ShotBar({ shots, pgmShotId, pvwShotId, origin, transition, editableGraphics, onTake, onPreview, renderProgramMonitor, renderPreviewMonitor, }: ShotBarProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { hideReasonLabel, prepareTake } from "./shot-bar-view-model.js";
|
|
4
|
+
import { useUIPrimitives } from "./ui-primitives.js";
|
|
5
|
+
function defaultOrigin() {
|
|
6
|
+
return typeof location !== "undefined" ? location.origin : "";
|
|
7
|
+
}
|
|
8
|
+
const FAMILY_BADGE = { geometry: "Geometry", graphic: "Graphic" };
|
|
9
|
+
export function ShotBar({ shots, pgmShotId, pvwShotId, origin, transition, editableGraphics = true, onTake, onPreview, renderProgramMonitor, renderPreviewMonitor, }) {
|
|
10
|
+
const { Button, Input } = useUIPrimitives();
|
|
11
|
+
const resolvedOrigin = origin ?? defaultOrigin();
|
|
12
|
+
const firstReady = shots.find((s) => s.status === "ready")?.id ?? null;
|
|
13
|
+
const [selectedId, setSelectedId] = useState(firstReady);
|
|
14
|
+
const [editedUrls, setEditedUrls] = useState({});
|
|
15
|
+
const selected = shots.find((s) => s.id === selectedId && s.status === "ready");
|
|
16
|
+
const commands = useMemo(() => selected ? prepareTake(selected, { origin: resolvedOrigin, editedUrl: editedUrls[selected.id], transition }) : [], [selected, resolvedOrigin, editedUrls, transition]);
|
|
17
|
+
return (_jsxs("div", { className: "flex flex-col gap-4 text-zinc-100", children: [_jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(Monitor, { label: "Preview", tone: "pvw", shot: findLabel(shots, pvwShotId), render: renderPreviewMonitor }), _jsx(Monitor, { label: "On Air", tone: "pgm", shot: findLabel(shots, pgmShotId), render: renderProgramMonitor })] }), _jsx("div", { className: "flex gap-3 overflow-x-auto pb-1", children: shots.map((shot) => (_jsx(ShotCard, { shot: shot, selected: shot.id === selectedId, onAir: shot.id === pgmShotId, config: shot.status === "ready" ? shot.config : undefined, onSelect: () => shot.status === "ready" && setSelectedId(shot.id) }, shot.id))) }), selected ? (_jsxs("div", { className: "flex flex-col gap-3 rounded-lg border border-zinc-800 bg-zinc-900/60 p-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("div", { className: "text-sm font-medium", children: selected.label }), _jsxs("div", { className: "flex gap-2", children: [onPreview ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => onPreview(commands, selected), children: "Preview" })) : null, _jsx(Button, { variant: "primary", size: "sm", onClick: () => onTake(commands, selected), children: "Take" })] })] }), editableGraphics && selected.graphicUrl !== undefined ? (_jsxs("div", { className: "flex flex-col gap-1 text-xs text-zinc-400", children: [_jsx("label", { htmlFor: "shotbar-graphic-url", children: "Graphic page" }), _jsx(Input, { id: "shotbar-graphic-url", type: "text", value: editedUrls[selected.id] ?? selected.graphicUrl, placeholder: selected.graphicUrl, onChange: (e) => setEditedUrls((prev) => ({ ...prev, [selected.id]: e.target.value })) })] })) : null, _jsx("pre", { className: "max-h-40 overflow-auto rounded bg-zinc-950 p-2 font-mono text-[11px] leading-relaxed text-zinc-400", children: JSON.stringify(commands, null, 2) })] })) : (_jsx("div", { className: "rounded-lg border border-dashed border-zinc-800 p-4 text-center text-sm text-zinc-500", children: "No shot selected \u2014 pick an available shot above." }))] }));
|
|
18
|
+
}
|
|
19
|
+
function findLabel(shots, id) {
|
|
20
|
+
if (!id)
|
|
21
|
+
return null;
|
|
22
|
+
return shots.find((s) => s.id === id)?.label ?? null;
|
|
23
|
+
}
|
|
24
|
+
function Monitor({ label, tone, shot, render, }) {
|
|
25
|
+
const ring = tone === "pgm" ? "border-red-600/70" : "border-emerald-600/70";
|
|
26
|
+
const chip = tone === "pgm" ? "bg-red-600 text-white" : "bg-emerald-600 text-white";
|
|
27
|
+
return (_jsxs("div", { className: `relative overflow-hidden rounded-lg border-2 ${ring} bg-black`, children: [_jsx("span", { className: `absolute left-2 top-2 z-10 rounded px-1.5 py-0.5 text-[10px] font-semibold ${chip}`, children: label }), _jsx("div", { className: "flex aspect-video items-center justify-center", children: render ? render() : _jsx("span", { className: "text-xs text-zinc-500", children: shot ?? "—" }) })] }));
|
|
28
|
+
}
|
|
29
|
+
function ShotCard({ shot, selected, onAir, config, onSelect, }) {
|
|
30
|
+
const hidden = shot.status === "hidden";
|
|
31
|
+
const border = onAir ? "border-red-600" : selected ? "border-sky-500" : "border-zinc-800 hover:border-zinc-600";
|
|
32
|
+
return (_jsxs("button", { type: "button", onClick: onSelect, disabled: hidden, title: hidden ? hideReasonLabel(shot.reason) : shot.label, className: `flex w-40 shrink-0 flex-col gap-2 rounded-lg border ${border} bg-zinc-900 p-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40`, children: [_jsx(ShotThumbnail, { preset: shot.status === "ready" ? shot.preset : "fullscreen", config: config }), _jsxs("div", { className: "flex items-center justify-between gap-1", children: [_jsx("span", { className: "truncate text-xs font-medium text-zinc-100", children: shot.label }), shot.status === "ready" ? (_jsx("span", { className: "shrink-0 rounded bg-zinc-800 px-1 text-[9px] uppercase tracking-wide text-zinc-400", children: FAMILY_BADGE[shot.family] })) : null] }), hidden ? (_jsx("span", { className: "text-[10px] leading-tight text-amber-500/80", children: hideReasonLabel(shot.reason) })) : null] }));
|
|
33
|
+
}
|
|
34
|
+
// A truthful 16:9 schematic of what the preset does — programme fill plus the
|
|
35
|
+
// preset's overlay/box geometry, read from config where it's positional.
|
|
36
|
+
function ShotThumbnail({ preset, config }) {
|
|
37
|
+
const c = (config ?? {});
|
|
38
|
+
const pct = (v, of) => `${Math.max(0, Math.min(100, (v / of) * 100))}%`;
|
|
39
|
+
const prog = _jsx("div", { className: "absolute inset-0 bg-sky-700/50" });
|
|
40
|
+
let overlay = null;
|
|
41
|
+
if (preset === "pip") {
|
|
42
|
+
overlay = (_jsx("div", { className: "absolute rounded-sm bg-amber-400/80", style: { right: "8%", bottom: "10%", width: pct(c.width ?? 384, 1920), height: pct(c.height ?? 270, 1080) } }));
|
|
43
|
+
}
|
|
44
|
+
else if (preset === "lbar") {
|
|
45
|
+
overlay = (_jsx("div", { className: "absolute rounded-sm bg-sky-700/70 ring-1 ring-sky-300/40", style: {
|
|
46
|
+
left: pct(c.videoX ?? 480, 1920),
|
|
47
|
+
top: pct(c.videoY ?? 0, 1080),
|
|
48
|
+
width: pct(c.videoWidth ?? 1440, 1920),
|
|
49
|
+
height: pct(c.videoHeight ?? 810, 1080),
|
|
50
|
+
} }));
|
|
51
|
+
}
|
|
52
|
+
else if (preset === "side-by-side") {
|
|
53
|
+
return (_jsxs("div", { className: "relative aspect-video w-full overflow-hidden rounded bg-zinc-950", children: [_jsx("div", { className: "absolute inset-y-0 left-0 w-1/2 bg-sky-700/50" }), _jsx("div", { className: "absolute inset-y-0 right-0 w-1/2 bg-emerald-700/50" })] }));
|
|
54
|
+
}
|
|
55
|
+
else if (preset === "quarters") {
|
|
56
|
+
return (_jsxs("div", { className: "relative grid aspect-video w-full grid-cols-2 grid-rows-2 gap-px overflow-hidden rounded bg-zinc-700", children: [_jsx("div", { className: "bg-sky-700/50" }), _jsx("div", { className: "bg-emerald-700/50" }), _jsx("div", { className: "bg-violet-700/50" }), _jsx("div", { className: "bg-amber-700/50" })] }));
|
|
57
|
+
}
|
|
58
|
+
else if (preset === "overlay" || preset === "lower-third") {
|
|
59
|
+
overlay = _jsx("div", { className: "absolute inset-x-[6%] bottom-[8%] h-[22%] rounded-sm bg-amber-400/80" });
|
|
60
|
+
}
|
|
61
|
+
return (_jsxs("div", { className: "relative aspect-video w-full overflow-hidden rounded bg-zinc-950", children: [prog, overlay] }));
|
|
62
|
+
}
|
package/components/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { ProductIframe, type ProductIframeHandle, type SubmitResult } from "./ProductIframe.js";
|
|
2
2
|
export { ProductTemplateBuildForm, type ProductTemplateBuildOutcome, type ProductTemplateBuildRequest, } from "./ProductTemplateBuildForm.js";
|
|
3
|
+
export { ShotBar, type ShotBarProps } from "./ShotBar.js";
|
|
4
|
+
export { hideReasonLabel, type PrepareTakeOptions, prepareTake, resolveGraphicUrl, } from "./shot-bar-view-model.js";
|
|
3
5
|
export { type ButtonProps, type ButtonSize, type ButtonVariant, type InputProps, type LabelProps, type UIPrimitives, UIPrimitivesProvider, useUIPrimitives, } from "./ui-primitives.js";
|
package/components/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { ProductIframe } from "./ProductIframe.js";
|
|
2
2
|
export { ProductTemplateBuildForm, } from "./ProductTemplateBuildForm.js";
|
|
3
|
+
export { ShotBar } from "./ShotBar.js";
|
|
4
|
+
export { hideReasonLabel, prepareTake, resolveGraphicUrl, } from "./shot-bar-view-model.js";
|
|
3
5
|
export { UIPrimitivesProvider, useUIPrimitives, } from "./ui-primitives.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type HideReason, type ResolvedShot, type TakeCommand, type Transition } from "@norskvideo/ctl-foundation/browser";
|
|
2
|
+
/** Operator-facing text for why a shot can't be taken right now. */
|
|
3
|
+
export declare function hideReasonLabel(reason: HideReason): string;
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a shot's graphic page to an absolute URL for `change-url`. Manifests
|
|
6
|
+
* ship relative paths (`/overlays/lbar.html`) so they're host-independent; the
|
|
7
|
+
* engine's `input.browser` needs an absolute http(s) URL. Absolute inputs pass
|
|
8
|
+
* through untouched, so an operator-typed full URL is honoured as-is.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveGraphicUrl(url: string, origin: string): string;
|
|
11
|
+
export interface PrepareTakeOptions {
|
|
12
|
+
/** Origin to resolve a relative graphic path against (usually `location.origin`). */
|
|
13
|
+
origin: string;
|
|
14
|
+
/** Operator-edited graphic URL from the card, if they changed it. */
|
|
15
|
+
editedUrl?: string;
|
|
16
|
+
transition?: Transition;
|
|
17
|
+
configOverride?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Assemble the exact `TakeCommand[]` for a shot: pick the graphic URL (edited
|
|
21
|
+
* over manifest), resolve it to absolute, and hand `buildTake` the right opts.
|
|
22
|
+
* The result is what the consumer sends over its component WebSocket, in order.
|
|
23
|
+
*/
|
|
24
|
+
export declare function prepareTake(shot: ResolvedShot, opts: PrepareTakeOptions): TakeCommand[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Pure view-model for the Shot Bar — the testable logic behind `ShotBar.tsx`,
|
|
2
|
+
// kept React-free so it can be unit-tested without a DOM (the SDK ships no
|
|
3
|
+
// render harness). The component stays a thin presentational shell over these.
|
|
4
|
+
import { buildTake, } from "@norskvideo/ctl-foundation/browser";
|
|
5
|
+
/** Operator-facing text for why a shot can't be taken right now. */
|
|
6
|
+
export function hideReasonLabel(reason) {
|
|
7
|
+
switch (reason) {
|
|
8
|
+
case "no-compose":
|
|
9
|
+
return "No compositor in this channel";
|
|
10
|
+
case "no-programme":
|
|
11
|
+
return "Waiting for programme";
|
|
12
|
+
case "no-overlay-layer":
|
|
13
|
+
return "No graphics overlay layer";
|
|
14
|
+
case "no-commentator-on-camera":
|
|
15
|
+
return "No commentator on camera";
|
|
16
|
+
case "commentator-count-mismatch":
|
|
17
|
+
return "Doesn't fit the commentators on camera";
|
|
18
|
+
case "no-contributor":
|
|
19
|
+
return "No contributor connected";
|
|
20
|
+
case "graphic-shot-missing-url":
|
|
21
|
+
return "No graphic page configured";
|
|
22
|
+
default:
|
|
23
|
+
// Unknown reason from an untyped boundary — show something honest rather
|
|
24
|
+
// than crash the whole bar.
|
|
25
|
+
return "Unavailable";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a shot's graphic page to an absolute URL for `change-url`. Manifests
|
|
30
|
+
* ship relative paths (`/overlays/lbar.html`) so they're host-independent; the
|
|
31
|
+
* engine's `input.browser` needs an absolute http(s) URL. Absolute inputs pass
|
|
32
|
+
* through untouched, so an operator-typed full URL is honoured as-is.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveGraphicUrl(url, origin) {
|
|
35
|
+
if (/^https?:\/\//i.test(url))
|
|
36
|
+
return url;
|
|
37
|
+
try {
|
|
38
|
+
return new URL(url, origin).toString();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// origin unusable (e.g. empty in a non-browser context) — return the raw
|
|
42
|
+
// value rather than throwing; the caller/engine will reject a bad URL.
|
|
43
|
+
return url;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Assemble the exact `TakeCommand[]` for a shot: pick the graphic URL (edited
|
|
48
|
+
* over manifest), resolve it to absolute, and hand `buildTake` the right opts.
|
|
49
|
+
* The result is what the consumer sends over its component WebSocket, in order.
|
|
50
|
+
*/
|
|
51
|
+
export function prepareTake(shot, opts) {
|
|
52
|
+
const rawUrl = opts.editedUrl ?? shot.graphicUrl;
|
|
53
|
+
const graphicUrl = rawUrl ? resolveGraphicUrl(rawUrl, opts.origin) : undefined;
|
|
54
|
+
return buildTake(shot, {
|
|
55
|
+
...(graphicUrl ? { graphicUrl } : {}),
|
|
56
|
+
...(opts.transition ? { transition: opts.transition } : {}),
|
|
57
|
+
...(opts.configOverride ? { configOverride: opts.configOverride } : {}),
|
|
58
|
+
});
|
|
59
|
+
}
|
package/package.json
CHANGED
package/product-service.d.ts
CHANGED
|
@@ -79,6 +79,21 @@ export interface ProductServiceOptions {
|
|
|
79
79
|
containerOps?: ProductContainerOps;
|
|
80
80
|
}
|
|
81
81
|
export declare class ProductService {
|
|
82
|
+
/**
|
|
83
|
+
* Serialises every mutation. `add` decides on a store read — which host port
|
|
84
|
+
* is free, whether the name is taken — and only commits after starting a
|
|
85
|
+
* container, waiting for it, fetching its manifest and probing it. That gap
|
|
86
|
+
* cannot move inside the store's `update` callback: the callback is sync,
|
|
87
|
+
* and a registration cannot be written under its name before `fetchManifest`
|
|
88
|
+
* supplies the name. Two concurrent adds therefore allocated the same host
|
|
89
|
+
* port and both stored under one name.
|
|
90
|
+
*
|
|
91
|
+
* This is a lock in a different module from the data it protects, which is
|
|
92
|
+
* ordinarily the shape to avoid — accepted here because the invariant spans
|
|
93
|
+
* side effects (a running container, an allocated port) the store cannot
|
|
94
|
+
* see. See ADR-0008.
|
|
95
|
+
*/
|
|
96
|
+
private readonly mutations;
|
|
82
97
|
private readonly store;
|
|
83
98
|
private readonly allocatePort;
|
|
84
99
|
private readonly importProductTemplateBytes?;
|
|
@@ -92,8 +107,11 @@ export declare class ProductService {
|
|
|
92
107
|
* the dev URL — registered no longer implies running. */
|
|
93
108
|
isRunning(reg: ProductRegistration): Promise<boolean>;
|
|
94
109
|
add(spec: ProductSpec, opts?: AddProductOpts): Promise<AddProductResult>;
|
|
110
|
+
private addSerialised;
|
|
95
111
|
remove(name: string): Promise<void>;
|
|
112
|
+
private removeSerialised;
|
|
96
113
|
reload(name: string): Promise<ProductRegistration>;
|
|
114
|
+
private reloadSerialised;
|
|
97
115
|
/** Stop every running container-kind product and clear its tracked
|
|
98
116
|
* containerId (model B: the daemon owns container lifecycle, so it reaps
|
|
99
117
|
* the control planes it started). Dev-kind products are externally owned —
|
|
@@ -102,6 +120,7 @@ export declare class ProductService {
|
|
|
102
120
|
* Best-effort per product — a failing `docker rm` is logged, never thrown,
|
|
103
121
|
* so one stubborn container can't block a clean shutdown. */
|
|
104
122
|
stopAll(): Promise<void>;
|
|
123
|
+
private stopAllSerialised;
|
|
105
124
|
/** Relaunch every container-kind product recorded in the store, refreshing
|
|
106
125
|
* its containerId (model B: called once at daemon boot to re-create the
|
|
107
126
|
* control planes stopped on the previous shutdown). Dev-kind products are
|
|
@@ -109,6 +128,7 @@ export declare class ProductService {
|
|
|
109
128
|
* come up is logged and left with its containerId cleared, so isRunning()
|
|
110
129
|
* reports it down rather than pointing at a container that never started. */
|
|
111
130
|
restoreAll(): Promise<void>;
|
|
131
|
+
private restoreAllSerialised;
|
|
112
132
|
/** Stop (if still present) and relaunch a single container-kind product,
|
|
113
133
|
* recording the fresh containerId. Used by the health monitor to recover a
|
|
114
134
|
* product that has failed its liveness probe. Unlike restoreAll this is not
|
|
@@ -117,4 +137,5 @@ export declare class ProductService {
|
|
|
117
137
|
* persisted before the readiness wait, so even a timed-out restart leaves a
|
|
118
138
|
* tracked container the next restart can reap rather than orphan. */
|
|
119
139
|
restart(name: string): Promise<void>;
|
|
140
|
+
private restartSerialised;
|
|
120
141
|
}
|
package/product-service.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { logger } from "@norskvideo/ctl-foundation";
|
|
2
|
+
import { logger, Mutex } from "@norskvideo/ctl-foundation";
|
|
3
3
|
import { validateDevUrl } from "./dev-url.js";
|
|
4
4
|
import { dockerRename, dockerRm, dockerRun, productContainerName } from "./docker-runner.js";
|
|
5
5
|
import { resolveLicenseFile } from "./license-registration.js";
|
|
@@ -31,6 +31,21 @@ const defaultContainerOps = {
|
|
|
31
31
|
waitForReady,
|
|
32
32
|
};
|
|
33
33
|
export class ProductService {
|
|
34
|
+
/**
|
|
35
|
+
* Serialises every mutation. `add` decides on a store read — which host port
|
|
36
|
+
* is free, whether the name is taken — and only commits after starting a
|
|
37
|
+
* container, waiting for it, fetching its manifest and probing it. That gap
|
|
38
|
+
* cannot move inside the store's `update` callback: the callback is sync,
|
|
39
|
+
* and a registration cannot be written under its name before `fetchManifest`
|
|
40
|
+
* supplies the name. Two concurrent adds therefore allocated the same host
|
|
41
|
+
* port and both stored under one name.
|
|
42
|
+
*
|
|
43
|
+
* This is a lock in a different module from the data it protects, which is
|
|
44
|
+
* ordinarily the shape to avoid — accepted here because the invariant spans
|
|
45
|
+
* side effects (a running container, an allocated port) the store cannot
|
|
46
|
+
* see. See ADR-0008.
|
|
47
|
+
*/
|
|
48
|
+
mutations = new Mutex();
|
|
34
49
|
store;
|
|
35
50
|
allocatePort;
|
|
36
51
|
importProductTemplateBytes;
|
|
@@ -57,6 +72,9 @@ export class ProductService {
|
|
|
57
72
|
return this.isDevUrlAlive(specBaseUrl(reg.spec));
|
|
58
73
|
}
|
|
59
74
|
async add(spec, opts = {}) {
|
|
75
|
+
return this.mutations.run(() => this.addSerialised(spec, opts));
|
|
76
|
+
}
|
|
77
|
+
async addSerialised(spec, opts) {
|
|
60
78
|
const existing = this.store.read();
|
|
61
79
|
let baseUrl;
|
|
62
80
|
let port;
|
|
@@ -222,6 +240,9 @@ export class ProductService {
|
|
|
222
240
|
return { registration, warnings };
|
|
223
241
|
}
|
|
224
242
|
async remove(name) {
|
|
243
|
+
return this.mutations.run(() => this.removeSerialised(name));
|
|
244
|
+
}
|
|
245
|
+
async removeSerialised(name) {
|
|
225
246
|
const existing = this.store.read();
|
|
226
247
|
const target = existing.find((p) => p.name === name);
|
|
227
248
|
if (!target)
|
|
@@ -232,6 +253,9 @@ export class ProductService {
|
|
|
232
253
|
logger.info(`Product '${name}' removed`);
|
|
233
254
|
}
|
|
234
255
|
async reload(name) {
|
|
256
|
+
return this.mutations.run(() => this.reloadSerialised(name));
|
|
257
|
+
}
|
|
258
|
+
async reloadSerialised(name) {
|
|
235
259
|
const existing = this.store.read();
|
|
236
260
|
const target = existing.find((p) => p.name === name);
|
|
237
261
|
if (!target)
|
|
@@ -254,6 +278,9 @@ export class ProductService {
|
|
|
254
278
|
* Best-effort per product — a failing `docker rm` is logged, never thrown,
|
|
255
279
|
* so one stubborn container can't block a clean shutdown. */
|
|
256
280
|
async stopAll() {
|
|
281
|
+
return this.mutations.run(() => this.stopAllSerialised());
|
|
282
|
+
}
|
|
283
|
+
async stopAllSerialised() {
|
|
257
284
|
const toStop = this.store.read().filter((p) => p.spec.kind === "container" && p.containerId !== undefined);
|
|
258
285
|
if (toStop.length === 0)
|
|
259
286
|
return;
|
|
@@ -276,6 +303,9 @@ export class ProductService {
|
|
|
276
303
|
* come up is logged and left with its containerId cleared, so isRunning()
|
|
277
304
|
* reports it down rather than pointing at a container that never started. */
|
|
278
305
|
async restoreAll() {
|
|
306
|
+
return this.mutations.run(() => this.restoreAllSerialised());
|
|
307
|
+
}
|
|
308
|
+
async restoreAllSerialised() {
|
|
279
309
|
const toRestore = this.store.read().filter((p) => p.spec.kind === "container");
|
|
280
310
|
if (toRestore.length === 0)
|
|
281
311
|
return;
|
|
@@ -308,6 +338,9 @@ export class ProductService {
|
|
|
308
338
|
* persisted before the readiness wait, so even a timed-out restart leaves a
|
|
309
339
|
* tracked container the next restart can reap rather than orphan. */
|
|
310
340
|
async restart(name) {
|
|
341
|
+
return this.mutations.run(() => this.restartSerialised(name));
|
|
342
|
+
}
|
|
343
|
+
async restartSerialised(name) {
|
|
311
344
|
const target = this.store.read().find((p) => p.name === name);
|
|
312
345
|
if (!target)
|
|
313
346
|
throw new ProductError("NOT_FOUND", `product '${name}' not registered`);
|