@norskvideo/ctl-sdk 0.1.29 → 0.1.31
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/ProductTemplateBuildForm.d.ts +9 -1
- package/components/ProductTemplateBuildForm.js +2 -2
- package/components/SectionFold.d.ts +13 -0
- package/components/SectionFold.js +22 -0
- package/components/VariableRowsEditor.d.ts +24 -0
- package/components/VariableRowsEditor.js +30 -0
- package/components/WorkdirAssetsEditor.d.ts +64 -0
- package/components/WorkdirAssetsEditor.js +129 -0
- package/components/index.d.ts +3 -0
- package/components/index.js +3 -0
- package/package.json +2 -2
- package/product-template-parsing.js +12 -0
- package/product-template-record.d.ts +9 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
1
2
|
/**
|
|
2
3
|
* Configure-iframe form. Produces a *product template*, NOT a running instance.
|
|
3
4
|
* Two submit paths:
|
|
@@ -29,7 +30,7 @@ export type ProductTemplateBuildOutcome<TProductTemplate> = {
|
|
|
29
30
|
ok: false;
|
|
30
31
|
error: string;
|
|
31
32
|
};
|
|
32
|
-
export declare function ProductTemplateBuildForm<TProductTemplate>({ productName, configScreenUrl, onClose, onSubmit, onSaved, onSavedAndLaunch, onRequirements, }: {
|
|
33
|
+
export declare function ProductTemplateBuildForm<TProductTemplate>({ productName, configScreenUrl, onClose, sections, onSubmit, onSaved, onSavedAndLaunch, onRequirements, }: {
|
|
33
34
|
productName: string;
|
|
34
35
|
configScreenUrl: string | null;
|
|
35
36
|
onClose: () => void;
|
|
@@ -41,6 +42,13 @@ export declare function ProductTemplateBuildForm<TProductTemplate>({ productName
|
|
|
41
42
|
* runner stores on disk; manager submits to manager-daemon. Returns the
|
|
42
43
|
* resulting product-template record (consumer-defined shape) on success.
|
|
43
44
|
*/
|
|
45
|
+
/** Runner-owned sections (placement policy, template variables, assets)
|
|
46
|
+
* rendered BETWEEN the product's config screen and Custom defaults — after
|
|
47
|
+
* what the product asks for, before the runner's advanced knobs. They live
|
|
48
|
+
* inside this form rather than above it so the whole create flow reads as
|
|
49
|
+
* one thing; floating them above the panel made them look unrelated to the
|
|
50
|
+
* form they are part of. */
|
|
51
|
+
sections?: ReactNode;
|
|
44
52
|
onSubmit: (req: ProductTemplateBuildRequest) => Promise<ProductTemplateBuildOutcome<TProductTemplate>>;
|
|
45
53
|
/** Save-only: product template persisted, form closes. */
|
|
46
54
|
onSaved?: (productTemplate: TProductTemplate) => void;
|
|
@@ -34,7 +34,7 @@ function assembleAdvancedDefaults(d) {
|
|
|
34
34
|
out.openFileLimit = { default: limit };
|
|
35
35
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
36
36
|
}
|
|
37
|
-
export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose, onSubmit, onSaved, onSavedAndLaunch, onRequirements, }) {
|
|
37
|
+
export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose, sections, onSubmit, onSaved, onSavedAndLaunch, onRequirements, }) {
|
|
38
38
|
const { Button, Input, Label } = useUIPrimitives();
|
|
39
39
|
const [productTemplateName, setProductTemplateName] = useState("");
|
|
40
40
|
// The iframe can suggest a name derived from its current choices (#317).
|
|
@@ -118,5 +118,5 @@ export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose
|
|
|
118
118
|
return (_jsxs("div", { className: "mb-6 rounded-lg border border-zinc-700 bg-zinc-900 p-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-4", children: [_jsxs("div", { children: [_jsxs("h4", { className: "text-sm font-semibold text-zinc-100", children: ["Create ", productName, " product template"] }), _jsx("p", { className: "text-xs text-zinc-500 mt-0.5", children: "Saves a reusable product template. Launch instances from it later." })] }), _jsx("button", { type: "button", onClick: onClose, className: "text-zinc-500 hover:text-zinc-300", children: _jsx(X, { size: 14 }) })] }), _jsxs("form", { onSubmit: handleSave, className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { children: "Product template name *" }), _jsx(Input, { value: productTemplateName, onChange: (e) => {
|
|
119
119
|
setNameTouched(true);
|
|
120
120
|
setProductTemplateName(e.target.value);
|
|
121
|
-
}, placeholder: suggestedName || `e.g. ${productName}-prod`, required: true }), productTemplateNameError ? (_jsx("p", { className: "text-xs text-red-400 mt-1", children: productTemplateNameError })) : (_jsx("p", { className: "text-xs text-zinc-500 mt-1", children: "Lowercase letters, numbers, and hyphens only. Used to launch instances later." }))] }), configScreenUrl ? (_jsx(ProductIframe, { ref: iframeRef, productName: productName, configScreenUrl: configScreenUrl, onValidityChange: (v) => setIframeValid(v), onSuggestName: handleSuggestName, ...(onRequirements ? { onRequirements } : {}) })) : (_jsxs("p", { className: "text-xs text-zinc-500", children: ["Product manifest does not declare ", _jsx("code", { children: "ui.configScreenUrl" }), " \u2014 nothing to configure."] })), _jsxs("div", { children: [_jsxs("button", { type: "button", onClick: () => setDefaultsOpen((v) => !v), className: "flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-500 hover:text-zinc-300", children: [defaultsOpen ? _jsx(ChevronDown, { size: 12 }) : _jsx(ChevronRight, { size: 12 }), "Custom defaults"] }), defaultsOpen && (_jsxs("div", { className: "mt-2", children: [_jsx("p", { className: "text-xs text-zinc-500 mb-2", children: "Launch-form defaults baked into this template's manifest. Operators can still override them per instance." }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [_jsxs("div", { children: [_jsx(Label, { children: "Network mode" }), _jsxs("select", { "aria-label": "Network mode", value: advancedDraft.networkMode, onChange: (e) => patchDraft({ networkMode: e.target.value }), className: "block rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-100", children: [_jsx("option", { value: "", children: "product default" }), _jsx("option", { value: "docker", children: "docker" }), _jsx("option", { value: "hybrid", children: "hybrid" })] })] }), _jsxs("div", { className: "min-w-[14rem] flex-1", children: [_jsx(Label, { children: "Host ports" }), _jsx(Input, { "aria-label": "Host ports", placeholder: "e.g. 1935, 5001/udp, 9000:80@svc", value: advancedDraft.hostPorts, onChange: (e) => patchDraft({ hostPorts: e.target.value }) })] }), _jsxs("div", { children: [_jsx(Label, { children: "Container user" }), _jsx(Input, { "aria-label": "Container user", placeholder: "uid:gid", value: advancedDraft.containerUser, onChange: (e) => patchDraft({ containerUser: e.target.value }), className: "w-28" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Shared memory size" }), _jsx(Input, { "aria-label": "Shared memory size", placeholder: "e.g. 8gb", value: advancedDraft.shmSize, onChange: (e) => patchDraft({ shmSize: e.target.value }), className: "w-24" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Open file limit" }), _jsx(Input, { "aria-label": "Open file limit", type: "number", min: 1024, max: 1048576, placeholder: "runner default", value: advancedDraft.openFileLimit, onChange: (e) => patchDraft({ openFileLimit: e.target.value }), className: "w-32" })] })] })] }))] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "primary", disabled: !canSubmit, onClick: handleSaveAndLaunch, children: submitting ? "Working…" : "Save & Launch…" }), _jsx(Button, { type: "submit", variant: "default", disabled: !canSubmit, children: "Save" }), _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, children: "Cancel" })] })] })] }));
|
|
121
|
+
}, placeholder: suggestedName || `e.g. ${productName}-prod`, required: true }), productTemplateNameError ? (_jsx("p", { className: "text-xs text-red-400 mt-1", children: productTemplateNameError })) : (_jsx("p", { className: "text-xs text-zinc-500 mt-1", children: "Lowercase letters, numbers, and hyphens only. Used to launch instances later." }))] }), configScreenUrl ? (_jsx(ProductIframe, { ref: iframeRef, productName: productName, configScreenUrl: configScreenUrl, onValidityChange: (v) => setIframeValid(v), onSuggestName: handleSuggestName, ...(onRequirements ? { onRequirements } : {}) })) : (_jsxs("p", { className: "text-xs text-zinc-500", children: ["Product manifest does not declare ", _jsx("code", { children: "ui.configScreenUrl" }), " \u2014 nothing to configure."] })), sections, _jsxs("div", { children: [_jsxs("button", { type: "button", onClick: () => setDefaultsOpen((v) => !v), className: "flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-500 hover:text-zinc-300", children: [defaultsOpen ? _jsx(ChevronDown, { size: 12 }) : _jsx(ChevronRight, { size: 12 }), "Custom defaults"] }), defaultsOpen && (_jsxs("div", { className: "mt-2", children: [_jsx("p", { className: "text-xs text-zinc-500 mb-2", children: "Launch-form defaults baked into this template's manifest. Operators can still override them per instance." }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [_jsxs("div", { children: [_jsx(Label, { children: "Network mode" }), _jsxs("select", { "aria-label": "Network mode", value: advancedDraft.networkMode, onChange: (e) => patchDraft({ networkMode: e.target.value }), className: "block rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-100", children: [_jsx("option", { value: "", children: "product default" }), _jsx("option", { value: "docker", children: "docker" }), _jsx("option", { value: "hybrid", children: "hybrid" })] })] }), _jsxs("div", { className: "min-w-[14rem] flex-1", children: [_jsx(Label, { children: "Host ports" }), _jsx(Input, { "aria-label": "Host ports", placeholder: "e.g. 1935, 5001/udp, 9000:80@svc", value: advancedDraft.hostPorts, onChange: (e) => patchDraft({ hostPorts: e.target.value }) })] }), _jsxs("div", { children: [_jsx(Label, { children: "Container user" }), _jsx(Input, { "aria-label": "Container user", placeholder: "uid:gid", value: advancedDraft.containerUser, onChange: (e) => patchDraft({ containerUser: e.target.value }), className: "w-28" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Shared memory size" }), _jsx(Input, { "aria-label": "Shared memory size", placeholder: "e.g. 8gb", value: advancedDraft.shmSize, onChange: (e) => patchDraft({ shmSize: e.target.value }), className: "w-24" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Open file limit" }), _jsx(Input, { "aria-label": "Open file limit", type: "number", min: 1024, max: 1048576, placeholder: "runner default", value: advancedDraft.openFileLimit, onChange: (e) => patchDraft({ openFileLimit: e.target.value }), className: "w-32" })] })] })] }))] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "primary", disabled: !canSubmit, onClick: handleSaveAndLaunch, children: submitting ? "Working…" : "Save & Launch…" }), _jsx(Button, { type: "submit", variant: "default", disabled: !canSubmit, children: "Save" }), _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, children: "Cancel" })] })] })] }));
|
|
122
122
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export interface SectionFoldProps {
|
|
3
|
+
name: string;
|
|
4
|
+
summary: string;
|
|
5
|
+
/** Amber the summary — an unsatisfied requirement, an invalid row. */
|
|
6
|
+
warn?: boolean;
|
|
7
|
+
defaultOpen?: boolean;
|
|
8
|
+
/** Rendered beside the header, OUTSIDE the toggle button (a button cannot
|
|
9
|
+
* legally nest another one). */
|
|
10
|
+
action?: ReactNode;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
export declare function SectionFold({ name, summary, warn, defaultOpen, action, children, }: SectionFoldProps): ReactNode;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A named, collapsible sub-editor whose header carries a one-line summary of
|
|
3
|
+
// what is set inside ("summary-first sections").
|
|
4
|
+
//
|
|
5
|
+
// Shared because it is the visual grammar of every multi-section form in the
|
|
6
|
+
// manager — the production composer's runtime cards, the product-template
|
|
7
|
+
// create panel — and a form that does not use it reads as belonging to a
|
|
8
|
+
// different application. norsk-ctl will want the same.
|
|
9
|
+
//
|
|
10
|
+
// The toggle is a button, so tests and keyboards address a section by name;
|
|
11
|
+
// `action` sits beside it for controls that must not nest inside a button.
|
|
12
|
+
//
|
|
13
|
+
// `defaultOpen` is worth thinking about rather than defaulting: a closed
|
|
14
|
+
// section whose summary names content the operator can edit reads as "here it
|
|
15
|
+
// is, and you may not change it" — which is exactly how the Assets section was
|
|
16
|
+
// reported as broken on 2026-09-02.
|
|
17
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
export function SectionFold({ name, summary, warn, defaultOpen = false, action, children, }) {
|
|
20
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
21
|
+
return (_jsxs("div", { className: "border-t border-zinc-800/60 first:border-t-0", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("button", { type: "button", onClick: () => setOpen((v) => !v), className: "-mx-1 flex w-full items-center gap-2 rounded px-1 py-2 text-left hover:bg-zinc-800/30", children: [open ? (_jsx(ChevronDown, { size: 12, className: "shrink-0 text-zinc-500" })) : (_jsx(ChevronRight, { size: 12, className: "shrink-0 text-zinc-500" })), _jsx("span", { className: "w-24 shrink-0 text-[10px] font-semibold uppercase tracking-widest text-zinc-400", children: name }), _jsx("span", { className: `min-w-0 flex-1 truncate text-xs ${warn ? "text-amber-400" : "text-zinc-500"}`, children: summary })] }), action] }), open && _jsx("div", { className: "pb-3 pl-5", children: children })] }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export interface VariableRow {
|
|
3
|
+
/** Stable across re-renders: rows are removable and the inputs are
|
|
4
|
+
* controlled, so keying by index makes a removal move the row below into
|
|
5
|
+
* the wrong state. */
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
value: string;
|
|
9
|
+
/** Only meaningful when `showBackup` is set; blank means "same as primary". */
|
|
10
|
+
backupValue?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function newVariableRow(): VariableRow;
|
|
13
|
+
export interface VariableRowsEditorProps {
|
|
14
|
+
rows: VariableRow[];
|
|
15
|
+
onChange: (rows: VariableRow[]) => void;
|
|
16
|
+
/** Why this row's name is unusable, or undefined. Called per row in order,
|
|
17
|
+
* so the implementation may track names it has already seen. */
|
|
18
|
+
nameError?: (name: string, index: number) => string | undefined;
|
|
19
|
+
/** Render the backup-value column (a production launching a backup copy). */
|
|
20
|
+
showBackup?: boolean;
|
|
21
|
+
addLabel?: string;
|
|
22
|
+
namePlaceholder?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function VariableRowsEditor({ rows, onChange, nameError, showBackup, addLabel, namePlaceholder, }: VariableRowsEditorProps): ReactNode;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Free-form environment-variable rows: NAME, a value, and (optionally) the
|
|
3
|
+
// value the backup replica should use instead.
|
|
4
|
+
//
|
|
5
|
+
// Shared because this is the same editor in two places that otherwise cannot
|
|
6
|
+
// share a form. The production composer uses it for variables the operator adds
|
|
7
|
+
// beyond what the product template declares; the product-template create panel
|
|
8
|
+
// uses it for the whole template tier, because at CREATION TIME there is no
|
|
9
|
+
// declared parameter list to tabulate — the tar has not been built yet. That
|
|
10
|
+
// asymmetry is why the composer's parameter TABLE cannot be reused here, and
|
|
11
|
+
// why this row editor is the part that can.
|
|
12
|
+
//
|
|
13
|
+
// Controlled, and name validation is injected: the reserved-prefix and
|
|
14
|
+
// uniqueness rules are the manager's vocabulary, not the SDK's.
|
|
15
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
16
|
+
import { useUIPrimitives } from "./ui-primitives.js";
|
|
17
|
+
let seq = 0;
|
|
18
|
+
export function newVariableRow() {
|
|
19
|
+
seq += 1;
|
|
20
|
+
return { id: `var-${seq}`, name: "", value: "" };
|
|
21
|
+
}
|
|
22
|
+
export function VariableRowsEditor({ rows, onChange, nameError, showBackup = false, addLabel = "Add variable", namePlaceholder = "MY_VAR", }) {
|
|
23
|
+
const { Button, Input } = useUIPrimitives();
|
|
24
|
+
const errors = rows.map((r, i) => nameError?.(r.name, i));
|
|
25
|
+
const update = (id, patch) => onChange(rows.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
|
26
|
+
return (_jsxs("div", { className: "space-y-1.5", children: [rows.map((row, i) => (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "w-48 shrink-0", children: _jsx(Input, { "aria-label": "variable name", className: "font-mono", placeholder: namePlaceholder, value: row.name,
|
|
27
|
+
// Env-var names are conventionally upper-case; doing it here
|
|
28
|
+
// saves the operator shouting and keeps the two hosts identical.
|
|
29
|
+
onChange: (e) => update(row.id, { name: e.target.value.toUpperCase() }) }) }), _jsx("div", { className: "min-w-0 flex-1", children: _jsx(Input, { "aria-label": `value for ${row.name || "new variable"}`, placeholder: "value", value: row.value, onChange: (e) => update(row.id, { value: e.target.value }) }) }), showBackup && (_jsx("div", { className: "min-w-0 flex-1", children: _jsx(Input, { "aria-label": `backup value for ${row.name || "new variable"}`, placeholder: "same as primary", value: row.backupValue ?? "", onChange: (e) => update(row.id, { backupValue: e.target.value }) }) })), _jsx(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": `remove variable ${row.name || "row"}`, onClick: () => onChange(rows.filter((r) => r.id !== row.id)), children: _jsx(Trash2, { size: 13 }) })] }), errors[i] && _jsx("p", { className: "mt-0.5 text-xs text-amber-400", children: errors[i] })] }, row.id))), _jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onChange([...rows, newVariableRow()]), children: [_jsx(Plus, { size: 12 }), " ", addLabel] })] }));
|
|
30
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { WorkdirFetchEntry } from "@norskvideo/ctl-product-template-schema/workdir-fetch";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
/** Where Studio keeps the documents it can open. A workflow is just an asset
|
|
4
|
+
* landing here — note `workflow.yml` exactly is the runner's read-only mount
|
|
5
|
+
* and is therefore refused by the path check. */
|
|
6
|
+
export declare const STUDIO_SAVE_FILES = "studio-save-files";
|
|
7
|
+
/** Where the working directory appears inside the containers. */
|
|
8
|
+
export declare const WORKDIR_MOUNT = "/data";
|
|
9
|
+
/** What the host's upload returns: a stored blob the entry can point at. */
|
|
10
|
+
export interface UploadedWorkdirAsset {
|
|
11
|
+
/** Reference the manager understands (`mgr:<sha256>`). */
|
|
12
|
+
url: string;
|
|
13
|
+
sha256: string;
|
|
14
|
+
filename: string;
|
|
15
|
+
sizeBytes: number;
|
|
16
|
+
}
|
|
17
|
+
/** An entry plus the two fields only the form needs. Both are stripped by
|
|
18
|
+
* `toWorkdirFetchEntries`, so neither can ride the wire.
|
|
19
|
+
*
|
|
20
|
+
* `rowId` exists because the destination is editable and rows are removable:
|
|
21
|
+
* keying the list by index makes React reuse an input across a removal, so
|
|
22
|
+
* deleting a middle row visibly moves the row below it into the wrong state. */
|
|
23
|
+
export type WorkdirAssetRow = WorkdirFetchEntry & {
|
|
24
|
+
rowId: string;
|
|
25
|
+
sizeBytes?: number;
|
|
26
|
+
};
|
|
27
|
+
/** Stored entries -> editable rows. */
|
|
28
|
+
export declare function toWorkdirAssetRows(entries: readonly WorkdirFetchEntry[] | undefined): WorkdirAssetRow[];
|
|
29
|
+
/** Editable rows -> what the API stores. */
|
|
30
|
+
export declare function toWorkdirFetchEntries(rows: readonly WorkdirAssetRow[]): WorkdirFetchEntry[];
|
|
31
|
+
/** The path as the container sees it — what the operator actually cares about,
|
|
32
|
+
* since that is what a product will open. */
|
|
33
|
+
export declare function containerPath(path: string): string;
|
|
34
|
+
export declare function describeWorkdirSource(url: string): string;
|
|
35
|
+
export declare function formatBytes(n: number): string;
|
|
36
|
+
/** Per-row problems, or undefined where the row is fine. Exported so a
|
|
37
|
+
* containing form can gate its own submit on the same rules the server
|
|
38
|
+
* applies — a form must not offer to save a list the API will reject. */
|
|
39
|
+
export declare function workdirAssetRowErrors(rows: readonly WorkdirFetchEntry[]): (string | undefined)[];
|
|
40
|
+
export interface WorkdirAssetsEditorProps {
|
|
41
|
+
rows: WorkdirAssetRow[];
|
|
42
|
+
onChange: (rows: WorkdirAssetRow[]) => void;
|
|
43
|
+
/** Store a file and return the reference an entry points at. The host owns
|
|
44
|
+
* the transport (and its size cap); this form only decides destinations. */
|
|
45
|
+
onUpload: (file: File) => Promise<UploadedWorkdirAsset>;
|
|
46
|
+
/** Surface an upload failure in the host's own way (a toast, a banner). */
|
|
47
|
+
onError?: (message: string) => void;
|
|
48
|
+
/** Offer workflow upload (into the workdir's studio-save-files/). For Norsk
|
|
49
|
+
* Studio itself; an appliance generates its one workflow and has nothing to
|
|
50
|
+
* select. */
|
|
51
|
+
allowWorkflowUploads?: boolean;
|
|
52
|
+
/** Offer the working-directory asset rows at all. Absent = true: every
|
|
53
|
+
* product gets a working directory, so the asset surface is universal and a
|
|
54
|
+
* product opts OUT rather than in (manifest `supportsCustomAssets`). */
|
|
55
|
+
allowAssetUploads?: boolean;
|
|
56
|
+
/** `null` suppresses it — for a host whose SectionFold already names the
|
|
57
|
+
* section, where a second heading is just noise. */
|
|
58
|
+
label?: string | null;
|
|
59
|
+
description?: ReactNode;
|
|
60
|
+
/** Rendered beside the URL field — hosts use it for a "what can I type here"
|
|
61
|
+
* interpolation hint, which is manager vocabulary rather than SDK. */
|
|
62
|
+
urlHint?: ReactNode;
|
|
63
|
+
}
|
|
64
|
+
export declare function WorkdirAssetsEditor({ rows, onChange, onUpload, onError, allowWorkflowUploads, allowAssetUploads, label, description, urlHint, }: WorkdirAssetsEditorProps): ReactNode;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The zod-free half: a browser bundle validates destinations without pulling a
|
|
3
|
+
// schema library in, while still using the exact rules the server enforces.
|
|
4
|
+
import { checkWorkdirPath } from "@norskvideo/ctl-product-template-schema/workdir-path";
|
|
5
|
+
import { FileUp, Link2, Trash2 } from "lucide-react";
|
|
6
|
+
import { useRef, useState } from "react";
|
|
7
|
+
import { useUIPrimitives } from "./ui-primitives.js";
|
|
8
|
+
/** Where Studio keeps the documents it can open. A workflow is just an asset
|
|
9
|
+
* landing here — note `workflow.yml` exactly is the runner's read-only mount
|
|
10
|
+
* and is therefore refused by the path check. */
|
|
11
|
+
export const STUDIO_SAVE_FILES = "studio-save-files";
|
|
12
|
+
/** Where the working directory appears inside the containers. */
|
|
13
|
+
export const WORKDIR_MOUNT = "/data";
|
|
14
|
+
let rowSeq = 0;
|
|
15
|
+
const nextRowId = () => {
|
|
16
|
+
rowSeq += 1;
|
|
17
|
+
return `asset-${rowSeq}`;
|
|
18
|
+
};
|
|
19
|
+
/** Stored entries -> editable rows. */
|
|
20
|
+
export function toWorkdirAssetRows(entries) {
|
|
21
|
+
return (entries ?? []).map((e) => ({ ...e, rowId: nextRowId() }));
|
|
22
|
+
}
|
|
23
|
+
/** Editable rows -> what the API stores. */
|
|
24
|
+
export function toWorkdirFetchEntries(rows) {
|
|
25
|
+
return rows.map(({ rowId: _rowId, sizeBytes: _sizeBytes, ...entry }) => entry);
|
|
26
|
+
}
|
|
27
|
+
/** The path as the container sees it — what the operator actually cares about,
|
|
28
|
+
* since that is what a product will open. */
|
|
29
|
+
export function containerPath(path) {
|
|
30
|
+
const checked = checkWorkdirPath(path);
|
|
31
|
+
return `${WORKDIR_MOUNT}/${checked.ok ? checked.path : path}`;
|
|
32
|
+
}
|
|
33
|
+
export function describeWorkdirSource(url) {
|
|
34
|
+
if (url.startsWith("mgr:"))
|
|
35
|
+
return "uploaded";
|
|
36
|
+
if (url.startsWith("s3://"))
|
|
37
|
+
return "S3";
|
|
38
|
+
return "URL";
|
|
39
|
+
}
|
|
40
|
+
export function formatBytes(n) {
|
|
41
|
+
if (n < 1024)
|
|
42
|
+
return `${n} B`;
|
|
43
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
44
|
+
let value = n / 1024;
|
|
45
|
+
let i = 0;
|
|
46
|
+
while (value >= 1024 && i < units.length - 1) {
|
|
47
|
+
value /= 1024;
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
|
|
51
|
+
}
|
|
52
|
+
const SOURCE_PATTERN = /^(mgr:|s3:\/\/|https:\/\/|http:\/\/)/;
|
|
53
|
+
/** Per-row problems, or undefined where the row is fine. Exported so a
|
|
54
|
+
* containing form can gate its own submit on the same rules the server
|
|
55
|
+
* applies — a form must not offer to save a list the API will reject. */
|
|
56
|
+
export function workdirAssetRowErrors(rows) {
|
|
57
|
+
const counts = new Map();
|
|
58
|
+
for (const r of rows) {
|
|
59
|
+
const checked = checkWorkdirPath(r.path);
|
|
60
|
+
const key = checked.ok ? checked.path : r.path;
|
|
61
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
62
|
+
}
|
|
63
|
+
return rows.map((r) => {
|
|
64
|
+
const checked = checkWorkdirPath(r.path);
|
|
65
|
+
if (!checked.ok)
|
|
66
|
+
return checked.problem.message;
|
|
67
|
+
// Within ONE tier a repeated destination is a mistake, not an override:
|
|
68
|
+
// there is no precedence to appeal to, so say so.
|
|
69
|
+
if ((counts.get(checked.path) ?? 0) > 1)
|
|
70
|
+
return "two assets target this destination";
|
|
71
|
+
if (!SOURCE_PATTERN.test(r.url))
|
|
72
|
+
return "source must be an s3:// or https:// URL";
|
|
73
|
+
return undefined;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export function WorkdirAssetsEditor({ rows, onChange, onUpload, onError, allowWorkflowUploads = false, allowAssetUploads = true, label = "Working-directory assets", description, urlHint, }) {
|
|
77
|
+
const { Button, Input, Label } = useUIPrimitives();
|
|
78
|
+
const [url, setUrl] = useState("");
|
|
79
|
+
const [uploading, setUploading] = useState(false);
|
|
80
|
+
const fileInput = useRef(null);
|
|
81
|
+
const workflowInput = useRef(null);
|
|
82
|
+
const errors = workdirAssetRowErrors(rows);
|
|
83
|
+
async function handleFiles(files, prefix, input) {
|
|
84
|
+
if (!files || files.length === 0)
|
|
85
|
+
return;
|
|
86
|
+
setUploading(true);
|
|
87
|
+
try {
|
|
88
|
+
const added = [];
|
|
89
|
+
for (const file of Array.from(files)) {
|
|
90
|
+
const stored = await onUpload(file);
|
|
91
|
+
// Default the destination to the bare filename, so it lands at
|
|
92
|
+
// /data/<file> — where a product's file picker looks. The studio
|
|
93
|
+
// section passes a prefix so workflows land where Studio lists them.
|
|
94
|
+
added.push({
|
|
95
|
+
rowId: nextRowId(),
|
|
96
|
+
path: `${prefix}${stored.filename}`,
|
|
97
|
+
url: stored.url,
|
|
98
|
+
name: stored.filename,
|
|
99
|
+
sha256: stored.sha256,
|
|
100
|
+
sizeBytes: stored.sizeBytes,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
onChange([...rows, ...added]);
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
onError?.(e instanceof Error ? e.message : String(e));
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
setUploading(false);
|
|
110
|
+
if (input)
|
|
111
|
+
input.value = "";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function addUrl() {
|
|
115
|
+
const trimmed = url.trim();
|
|
116
|
+
if (trimmed === "")
|
|
117
|
+
return;
|
|
118
|
+
const basename = trimmed.split("?")[0]?.split("/").pop() ?? "asset";
|
|
119
|
+
onChange([...rows, { rowId: nextRowId(), path: basename, url: trimmed }]);
|
|
120
|
+
setUrl("");
|
|
121
|
+
}
|
|
122
|
+
const update = (i, patch) => onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)));
|
|
123
|
+
return (_jsxs("div", { children: [label !== null && _jsx(Label, { children: label }), _jsx("p", { className: "mb-2 text-xs text-zinc-500", children: description ?? (_jsxs(_Fragment, { children: ["Files placed in the runtime's working directory before it starts. The working directory is mounted at", " ", _jsx("code", { className: "font-mono", children: WORKDIR_MOUNT }), " inside the containers."] })) }), rows.length === 0 && _jsx("p", { className: "mb-2 text-xs text-zinc-600", children: "Nothing attached." }), rows.length > 0 && (_jsx("div", { className: "mb-3 space-y-1.5", children: rows.map((row, i) => (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { "aria-label": `destination for ${row.name ?? row.url}`, className: "flex-1 font-mono", placeholder: "bumper.mp4", value: row.path, onChange: (e) => update(i, { path: e.target.value }) }), _jsx("span", { className: "shrink-0 text-[9px] uppercase tracking-widest text-zinc-500", children: describeWorkdirSource(row.url) }), row.sizeBytes !== undefined && (_jsx("span", { className: "shrink-0 text-xs text-zinc-500", children: formatBytes(row.sizeBytes) })), _jsxs("label", { className: "flex shrink-0 items-center gap-1 text-xs text-zinc-500", title: "A missing object is skipped instead of failing the launch \u2014 for a per-event URL that may not exist yet", children: [_jsx("input", { type: "checkbox", "aria-label": `${row.path} is optional`, checked: row.optional === true, onChange: (e) => update(i, { optional: e.target.checked }) }), "optional"] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": `remove asset ${row.path || "row"}`, onClick: () => onChange(rows.filter((_, j) => j !== i)), children: _jsx(Trash2, { size: 13 }) })] }), _jsxs("div", { className: "mt-0.5 flex gap-3 pl-1 text-xs", children: [errors[i] ? (_jsx("span", { className: "text-amber-400", children: errors[i] })) : (_jsx("span", { className: "font-mono text-zinc-600", children: containerPath(row.path) })), !row.url.startsWith("mgr:") && _jsxs("span", { className: "truncate text-zinc-600", children: ["\u2190 ", row.url] })] })] }, row.rowId))) })), allowAssetUploads && (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("input", { ref: fileInput, type: "file", multiple: true, "aria-label": "upload assets", className: "hidden", onChange: (e) => void handleFiles(e.target.files, "", fileInput.current) }), _jsxs(Button, { type: "button", size: "sm", variant: "ghost", disabled: uploading, onClick: () => fileInput.current?.click(), children: [_jsx(FileUp, { size: 12 }), " ", uploading ? "Uploading…" : "Upload file"] }), _jsxs("div", { className: "flex flex-1 items-center gap-2", children: [_jsx(Input, { "aria-label": "asset URL", className: "flex-1 font-mono", placeholder: "s3://rundowns/${production}/media.ts", value: url, onChange: (e) => setUrl(e.target.value), onKeyDown: (e) => {
|
|
124
|
+
if (e.key === "Enter") {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
addUrl();
|
|
127
|
+
}
|
|
128
|
+
} }), urlHint, _jsxs(Button, { type: "button", size: "sm", variant: "ghost", disabled: url.trim() === "", onClick: addUrl, children: [_jsx(Link2, { size: 12 }), " Add URL"] })] })] })), allowAssetUploads && (_jsx("p", { className: "mt-1 text-xs text-zinc-600", children: "Large media belongs behind a URL \u2014 the node fetches it directly, and identical files are downloaded once per node however many runtimes share them. Uploads are for small files and are capped." })), allowWorkflowUploads && (_jsxs("div", { className: "mt-4 border-t border-zinc-800/60 pt-3", children: [_jsx(Label, { children: "Studio workflows" }), _jsxs("p", { className: "mb-2 text-xs text-zinc-500", children: ["Workflow documents land in", " ", _jsxs("code", { className: "font-mono", children: [WORKDIR_MOUNT, "/", STUDIO_SAVE_FILES] }), ", where Studio lists them for the operator to open."] }), _jsx("input", { ref: workflowInput, type: "file", multiple: true, accept: ".yml,.yaml,.json", "aria-label": "upload workflows", className: "hidden", onChange: (e) => void handleFiles(e.target.files, `${STUDIO_SAVE_FILES}/`, workflowInput.current) }), _jsxs(Button, { type: "button", size: "sm", variant: "ghost", disabled: uploading, onClick: () => workflowInput.current?.click(), children: [_jsx(FileUp, { size: 12 }), " Upload workflow"] }), _jsxs("p", { className: "mt-1 text-xs text-zinc-600", children: [_jsxs("code", { className: "font-mono", children: [STUDIO_SAVE_FILES, "/workflow.yml"] }), " exactly is reserved: a product template that ships one mounts it read-only and runs Studio headless against it, so an upload cannot use that name."] })] }))] }));
|
|
129
|
+
}
|
package/components/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { type ComposeLayer, ComposeLayerPreview, type ComposeLayerPreviewProps } from "./ComposeLayerPreview.js";
|
|
2
2
|
export { ProductIframe, type ProductIframeHandle, type SubmitResult } from "./ProductIframe.js";
|
|
3
3
|
export { ProductTemplateBuildForm, type ProductTemplateBuildOutcome, type ProductTemplateBuildRequest, } from "./ProductTemplateBuildForm.js";
|
|
4
|
+
export { SectionFold, type SectionFoldProps } from "./SectionFold.js";
|
|
4
5
|
export { ShotBar, type ShotBarProps } from "./ShotBar.js";
|
|
5
6
|
export { graphicResolvable, hideReasonLabel, type PrepareTakeOptions, prepareTake, resolveGraphicUrl, } from "./shot-bar-view-model.js";
|
|
6
7
|
export { type ButtonProps, type ButtonSize, type ButtonVariant, type InputProps, type LabelProps, type UIPrimitives, UIPrimitivesProvider, useUIPrimitives, } from "./ui-primitives.js";
|
|
8
|
+
export { newVariableRow, type VariableRow, VariableRowsEditor, type VariableRowsEditorProps, } from "./VariableRowsEditor.js";
|
|
9
|
+
export { containerPath, describeWorkdirSource, formatBytes, STUDIO_SAVE_FILES, toWorkdirAssetRows, toWorkdirFetchEntries, type UploadedWorkdirAsset, WORKDIR_MOUNT, type WorkdirAssetRow, WorkdirAssetsEditor, type WorkdirAssetsEditorProps, workdirAssetRowErrors, } from "./WorkdirAssetsEditor.js";
|
package/components/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { ComposeLayerPreview } from "./ComposeLayerPreview.js";
|
|
2
2
|
export { ProductIframe } from "./ProductIframe.js";
|
|
3
3
|
export { ProductTemplateBuildForm, } from "./ProductTemplateBuildForm.js";
|
|
4
|
+
export { SectionFold } from "./SectionFold.js";
|
|
4
5
|
export { ShotBar } from "./ShotBar.js";
|
|
5
6
|
export { graphicResolvable, hideReasonLabel, prepareTake, resolveGraphicUrl, } from "./shot-bar-view-model.js";
|
|
6
7
|
export { UIPrimitivesProvider, useUIPrimitives, } from "./ui-primitives.js";
|
|
8
|
+
export { newVariableRow, VariableRowsEditor, } from "./VariableRowsEditor.js";
|
|
9
|
+
export { containerPath, describeWorkdirSource, formatBytes, STUDIO_SAVE_FILES, toWorkdirAssetRows, toWorkdirFetchEntries, WORKDIR_MOUNT, WorkdirAssetsEditor, workdirAssetRowErrors, } from "./WorkdirAssetsEditor.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@norskvideo/ctl-sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"lucide-react": "^0.483.0",
|
|
43
43
|
"react": "^19.0.0",
|
|
44
44
|
"react-hot-toast": "^2.4.1",
|
|
45
|
-
"zod": "
|
|
45
|
+
"zod": "4.4.3"
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ProductTemplateManifestSchema } from "@norskvideo/ctl-product-template-schema";
|
|
2
|
+
import { WorkdirFetchListSchema } from "@norskvideo/ctl-product-template-schema/workdir-fetch";
|
|
2
3
|
export class ProductTemplatesParseError extends Error {
|
|
3
4
|
constructor(path, reason) {
|
|
4
5
|
super(`Failed to parse ${path}: ${reason}`);
|
|
@@ -57,6 +58,17 @@ export function parseProductTemplatesFile(raw, path) {
|
|
|
57
58
|
entry.suggestedSecurityGroups.every((l) => typeof l === "string")) {
|
|
58
59
|
rec.suggestedSecurityGroups = entry.suggestedSecurityGroups;
|
|
59
60
|
}
|
|
61
|
+
// Assets are validated by the schema rather than shape-sniffed: a
|
|
62
|
+
// malformed entry here would launch a production silently missing its
|
|
63
|
+
// media, so a bad list is dropped loudly by the writer, not tolerated.
|
|
64
|
+
if (Array.isArray(entry.assets)) {
|
|
65
|
+
const assetsParse = WorkdirFetchListSchema.safeParse(entry.assets);
|
|
66
|
+
if (!assetsParse.success) {
|
|
67
|
+
throw new ProductTemplatesParseError(path, `${where}.assets is invalid: ${assetsParse.error.message}`);
|
|
68
|
+
}
|
|
69
|
+
if (assetsParse.data.length > 0)
|
|
70
|
+
rec.assets = assetsParse.data;
|
|
71
|
+
}
|
|
60
72
|
if (isRecord(entry.source)) {
|
|
61
73
|
if (entry.source.kind === "import" && typeof entry.source.fromFile === "string") {
|
|
62
74
|
rec.source = { kind: "import", fromFile: entry.source.fromFile };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ProductTemplateManifest, ProductTemplateParameter } from "@norskvideo/ctl-product-template-schema";
|
|
2
|
+
import type { WorkdirFetchList } from "@norskvideo/ctl-product-template-schema/workdir-fetch";
|
|
2
3
|
/**
|
|
3
4
|
* How a stored product template got here.
|
|
4
5
|
*
|
|
@@ -63,4 +64,12 @@ export interface ProductTemplateRecord {
|
|
|
63
64
|
* can untick, never a pin, and never a wire field of its own: the
|
|
64
65
|
* confirmed selection rides the job as extraSecurityGroups ids. */
|
|
65
66
|
suggestedSecurityGroups?: string[];
|
|
67
|
+
/** Template-author working-directory assets — the product-template tier of
|
|
68
|
+
* the asset stack (working-directory-and-assets.md). Each entry names a
|
|
69
|
+
* workdir-relative destination and where the bytes come from (an uploaded
|
|
70
|
+
* blob served by the manager, or an s3:// / https:// URL the node fetches
|
|
71
|
+
* directly). Merged beneath the production-template and launch tiers and
|
|
72
|
+
* carried to the worker as the reserved NORSK_WORKDIR_FETCH parameter, so
|
|
73
|
+
* the product template's own tar stays immutable. */
|
|
74
|
+
assets?: WorkdirFetchList;
|
|
66
75
|
}
|