Added config editor in front end
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { LayerStack } from "./components/LayerStack";
|
||||
import { ConfigEditor } from "./components/ConfigEditor";
|
||||
import { StackPresetToolbar } from "./components/StackPresetToolbar";
|
||||
import { StatusPanels } from "./components/StatusPanels";
|
||||
import { useRuntimeState } from "./hooks/useRuntimeState";
|
||||
@@ -91,6 +92,7 @@ function App() {
|
||||
<section className="dashboard-grid">
|
||||
<StatusPanels app={app} performance={performance} runtime={runtime} video={video} videoOutput={videoOutput} />
|
||||
<StackPresetToolbar />
|
||||
<ConfigEditor />
|
||||
</section>
|
||||
|
||||
<LayerStack
|
||||
|
||||
@@ -5,3 +5,21 @@ export function postJson(path, payload) {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchJson(path) {
|
||||
const response = await fetch(path);
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error || `Request failed: ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function postJsonResult(path, payload) {
|
||||
const response = await postJson(path, payload);
|
||||
const body = await response.json();
|
||||
if (!response.ok || body?.ok === false) {
|
||||
throw new Error(body?.error || `Request failed: ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
251
ui/src/components/ConfigEditor.jsx
Normal file
251
ui/src/components/ConfigEditor.jsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Power, RefreshCw, Save } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { fetchJson, postJsonResult } from "../api/controlApi";
|
||||
|
||||
const backendOptions = ["decklink", "ndi", "none"];
|
||||
const resolutionOptions = ["720p", "1080p", "2160p"];
|
||||
const frameRateOptions = ["23.98", "24", "25", "29.97", "30", "50", "59.94", "60"];
|
||||
const pixelFormatOptions = ["auto", "bgra8", "uyvy8"];
|
||||
|
||||
function cloneConfig(config) {
|
||||
return JSON.parse(JSON.stringify(config ?? {}));
|
||||
}
|
||||
|
||||
function readPath(object, path) {
|
||||
return path.split(".").reduce((value, key) => value?.[key], object);
|
||||
}
|
||||
|
||||
function writePath(object, path, value) {
|
||||
const keys = path.split(".");
|
||||
const root = cloneConfig(object);
|
||||
let target = root;
|
||||
for (let index = 0; index < keys.length - 1; ++index) {
|
||||
const key = keys[index];
|
||||
target[key] = target[key] ?? {};
|
||||
target = target[key];
|
||||
}
|
||||
target[keys[keys.length - 1]] = value;
|
||||
return root;
|
||||
}
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<label className="config-field">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({ config, label, path, setConfig }) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<input
|
||||
type="text"
|
||||
value={readPath(config, path) ?? ""}
|
||||
onChange={(event) => setConfig((current) => writePath(current, path, event.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ config, label, min, path, setConfig, step = 1 }) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<input
|
||||
min={min}
|
||||
step={step}
|
||||
type="number"
|
||||
value={readPath(config, path) ?? 0}
|
||||
onChange={(event) => setConfig((current) => writePath(current, path, Number(event.target.value)))}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({ config, label, options, path, setConfig }) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<select
|
||||
value={readPath(config, path) ?? options[0]}
|
||||
onChange={(event) => setConfig((current) => writePath(current, path, event.target.value))}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleField({ config, label, path, setConfig }) {
|
||||
return (
|
||||
<label className="toggle toggle--field config-toggle">
|
||||
<input
|
||||
checked={Boolean(readPath(config, path))}
|
||||
type="checkbox"
|
||||
onChange={(event) => setConfig((current) => writePath(current, path, event.target.checked))}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigEditor() {
|
||||
const [draft, setDraft] = useState(null);
|
||||
const [saved, setSaved] = useState(null);
|
||||
const [path, setPath] = useState("");
|
||||
const [restartRequired, setRestartRequired] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const dirty = useMemo(() => JSON.stringify(draft ?? {}) !== JSON.stringify(saved ?? {}), [draft, saved]);
|
||||
|
||||
async function loadConfig() {
|
||||
setBusy(true);
|
||||
setStatus("");
|
||||
try {
|
||||
const response = await fetchJson("/api/config");
|
||||
const nextConfig = response.disk ?? response.active ?? {};
|
||||
setDraft(cloneConfig(nextConfig));
|
||||
setSaved(cloneConfig(nextConfig));
|
||||
setPath(response.path ?? "");
|
||||
setRestartRequired(Boolean(response.restartRequired));
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
async function saveConfig() {
|
||||
if (!draft) return;
|
||||
setBusy(true);
|
||||
setStatus("");
|
||||
try {
|
||||
await postJsonResult("/api/config/save", draft);
|
||||
setSaved(cloneConfig(draft));
|
||||
setRestartRequired(true);
|
||||
setStatus("Saved");
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restartApp() {
|
||||
setBusy(true);
|
||||
setStatus("");
|
||||
try {
|
||||
await postJsonResult("/api/app/restart", {});
|
||||
setStatus("Restarting");
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<div className="panel config-panel">
|
||||
<div className="panel__header">
|
||||
<h3>Host config</h3>
|
||||
<button type="button" className="icon-button" onClick={loadConfig} title="Reload config">
|
||||
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">{status || "Loading config."}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel config-panel">
|
||||
<div className="panel__header config-panel__header">
|
||||
<div>
|
||||
<h3>Host config</h3>
|
||||
<p className="muted">{path || "runtime-host.json"}</p>
|
||||
</div>
|
||||
<div className="config-panel__actions">
|
||||
<button type="button" className="icon-button" onClick={loadConfig} disabled={busy} title="Reload config">
|
||||
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="button-with-icon" onClick={saveConfig} disabled={busy || !dirty}>
|
||||
<Save size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-with-icon config-panel__restart"
|
||||
onClick={restartApp}
|
||||
disabled={busy || dirty || !restartRequired}
|
||||
>
|
||||
<Power size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-grid">
|
||||
<section className="config-section">
|
||||
<h4>Runtime</h4>
|
||||
<div className="config-fields config-fields--wide">
|
||||
<TextField config={draft} label="Shader library" path="shaderLibrary" setConfig={setDraft} />
|
||||
<TextField config={draft} label="Startup shader" path="runtimeShaderId" setConfig={setDraft} />
|
||||
<NumberField config={draft} label="Server port" min={1} path="serverPort" setConfig={setDraft} />
|
||||
<NumberField config={draft} label="Temporal cap" min={0} path="maxTemporalHistoryFrames" setConfig={setDraft} />
|
||||
<ToggleField config={draft} label="Auto reload" path="autoReload" setConfig={setDraft} />
|
||||
<ToggleField config={draft} label="Preview" path="previewEnabled" setConfig={setDraft} />
|
||||
<NumberField config={draft} label="Preview fps" min={1} path="previewFps" setConfig={setDraft} step={0.01} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h4>Input</h4>
|
||||
<div className="config-fields">
|
||||
<SelectField config={draft} label="Backend" options={backendOptions} path="input.backend" setConfig={setDraft} />
|
||||
<TextField config={draft} label="Device" path="input.device" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Resolution" options={resolutionOptions} path="input.resolution" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Frame rate" options={frameRateOptions} path="input.frameRate" setConfig={setDraft} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h4>Output</h4>
|
||||
<div className="config-fields">
|
||||
<SelectField config={draft} label="Backend" options={backendOptions} path="output.backend" setConfig={setDraft} />
|
||||
<TextField config={draft} label="Device" path="output.device" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Resolution" options={resolutionOptions} path="output.resolution" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Frame rate" options={frameRateOptions} path="output.frameRate" setConfig={setDraft} />
|
||||
<SelectField config={draft} label="Pixel format" options={pixelFormatOptions} path="output.pixelFormat" setConfig={setDraft} />
|
||||
<ToggleField config={draft} label="External key" path="output.keying.external" setConfig={setDraft} />
|
||||
<ToggleField config={draft} label="Alpha required" path="output.keying.alphaRequired" setConfig={setDraft} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h4>OSC</h4>
|
||||
<div className="config-fields">
|
||||
<TextField config={draft} label="Bind address" path="oscBindAddress" setConfig={setDraft} />
|
||||
<NumberField config={draft} label="Port" min={1} path="oscPort" setConfig={setDraft} />
|
||||
<NumberField config={draft} label="Smoothing" min={0} path="oscSmoothing" setConfig={setDraft} step={0.01} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{(status || dirty || restartRequired) && (
|
||||
<p className={`config-status${status && status !== "Saved" && status !== "Restarting" ? " config-status--error" : ""}`}>
|
||||
{status || (dirty ? "Unsaved changes" : "Restart required")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -239,7 +239,8 @@ pre {
|
||||
|
||||
.panel--compiler,
|
||||
.panel--telemetry,
|
||||
.stack-panel {
|
||||
.stack-panel,
|
||||
.config-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -509,6 +510,96 @@ pre {
|
||||
min-width: 8.75rem;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.config-panel__header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.config-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.config-panel__restart {
|
||||
background: #b42318;
|
||||
border-color: #8f1d13;
|
||||
color: #fff7f5;
|
||||
}
|
||||
|
||||
.config-panel__restart:hover:not(:disabled) {
|
||||
background: #912018;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.65rem;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: var(--app-radius);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.config-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.config-fields--wide {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.config-field {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
color: var(--app-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.config-field input,
|
||||
.config-field select {
|
||||
min-height: 38px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
color: var(--app-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.config-toggle {
|
||||
align-self: end;
|
||||
min-height: 38px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: var(--app-radius);
|
||||
background: var(--app-surface-2);
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.config-status {
|
||||
margin: 0;
|
||||
color: #c5efd3;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.config-status--error {
|
||||
color: #ffd0cf;
|
||||
}
|
||||
|
||||
.stack-panel__screenshot {
|
||||
min-width: 8.75rem;
|
||||
}
|
||||
@@ -1192,7 +1283,9 @@ pre {
|
||||
}
|
||||
|
||||
.dashboard-grid,
|
||||
.stack-panel__grid {
|
||||
.stack-panel__grid,
|
||||
.config-grid,
|
||||
.config-fields--wide {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1226,6 +1319,7 @@ pre {
|
||||
.definition-grid,
|
||||
.summary-grid,
|
||||
.kv-rows,
|
||||
.config-fields,
|
||||
.parameter-grid,
|
||||
.parameter,
|
||||
.parameter__header {
|
||||
|
||||
Reference in New Issue
Block a user