config uislopping

This commit is contained in:
legop3
2026-09-14 03:06:41 -04:00
parent 81994f8a56
commit 3d2e75572f
16 changed files with 243 additions and 135 deletions
+2
View File
@@ -20,6 +20,7 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
## Decision log
- 2026-09-14: Render the schema-driven configuration editor through one generic MultiRover RJSF theme. Top-level objects use `CardFrame`, nested objects and array items have explicit boundaries, and array operations use visible text controls beside their item. Responsive field columns remain, but no toolbar or item action is pushed to the far edge of a wide section.
- 2026-09-14: Treat container deployment as a fresh installation. Neither startup nor the installer searches for, imports, removes, or otherwise manages an old `config.yaml`; the only old-file path retained is an operator-selected YAML upload on `/setup`. The separate command-line importer and its dry-run mode are removed. Internal SQLite schema migrations remain because they evolve the active database rather than discovering an old installation.
- 2026-09-14: Keep the one-time first-run setup code in `data/setup-code.txt` with owner-only permissions instead of writing the credential into server logs. Reuse it across restarts and delete it permanently when setup completes.
- 2026-09-14: Feature enablement is exactly the service-owned `enabled` boolean. A service-owned configuration definition marks itself with `feature: true` when that switch belongs in the public feature map; the configuration system derives the map for sessions and command availability, including nested service definitions, without a separate feature registry. Missing credentials, hardware, connections, data, or enabled dependencies are runtime health conditions and never silently change that choice.
@@ -441,6 +442,7 @@ Implemented on 2026-09-14:
- Added safe empty-data startup, a file-backed one-time setup code, the restricted `/setup` route, and a console administrator-recovery command. The credential persists at `data/setup-code.txt` across restarts with `0600` permissions, never appears in logs, and is deleted when setup completes.
- Added the centralized `/admin` route with Overview, Fleet operations, Users and administrators, and one schema-generated hierarchical Configuration page in legacy YAML order.
- Replaced every feature-specific configuration form with `@rjsf/core`; the protected admin snapshot supplies the server's assembled schema, and one generic widget handles all schema-declared secrets.
- Replaced RJSF's unthemed Bootstrap markup with generic MultiRover object, field, and array templates. Configuration sections now use the same cards and surfaces as the driver UI, array controls are readable text beside each item, and the shared responsive grid never sends actions to the far edge of a wide panel.
- Converged feature control into service-owned configuration: each public feature opts in beside its own schema, and the configuration system derives those exact `enabled` switches for sessions and command discovery. The former server feature registry was removed; configuration completeness and hardware availability remain visible as runtime status instead of becoming hidden enablement rules.
- Lazy-loaded setup and administration so the schema-form dependency is not included in ordinary driver-page downloads.
- Reused the existing fleet and identity administration surfaces, added password reconfirmation for sensitive operations, and prevented removal or demotion of the final lockdown administrator.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-CRbxLzE1.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-Dv9kbAgD.js.map
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-DA_yf5GI.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-CFDe8tZ-.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-CRbxLzE1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C935Fjdg.css">
<script type="module" crossorigin src="/assets/index-DA_yf5GI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DAc-5H0K.css">
</head>
<body>
<div id="root"></div>
+16 -28
View File
@@ -7,6 +7,7 @@ import AuthPanel from '../components/AuthPanel/index.jsx';
import AdminPanelContent from '../components/AdminPanel/AdminPanelContent.jsx';
import CardFrame from '../components/CardFrame/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import Tabs, { Tab, TabList, TabPanels } from '../components/Tabs/index.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import IdentityDatabasePanel from '../database/IdentityDatabasePanel.jsx';
@@ -26,18 +27,6 @@ const TOP_LEVEL_SECTIONS = [
{ key: 'configuration', label: 'Configuration', lockdownOnly: true },
];
function NavigationButton({ active, children, onClick }) {
return (
<button
type="button"
className={`w-full px-1 py-0.5 text-left text-xs ${active ? 'bg-sky-800 text-white' : 'surface hover:bg-neutral-700'}`}
onClick={onClick}
>
{children}
</button>
);
}
export default function AdminApp() {
useUserIdentitySync({ identitySurface: 'passive' });
const socket = useSocket();
@@ -125,11 +114,12 @@ export default function AdminApp() {
}
const navigationOptions = TOP_LEVEL_SECTIONS.filter((entry) => !entry.lockdownOnly || isLockdown);
const activeSection = navigationOptions.some((entry) => entry.key === selected) ? selected : 'overview';
let content;
if (!isAdmin) {
content = <div className="mx-auto w-full max-w-md"><AuthPanel /></div>;
} else if (selected === 'fleet') {
} else if (activeSection === 'fleet') {
content = <AdminPanelContent />;
} else if (!isLockdown) {
content = (
@@ -139,14 +129,14 @@ export default function AdminApp() {
);
} else if (!snapshot) {
content = <CardFrame title="Loading administration" bodyClassName="p-1 text-sm text-slate-300"><p>{loadingError || 'Loading configuration and audit state…'}</p></CardFrame>;
} else if (selected === 'users') {
} else if (activeSection === 'users') {
content = (
<div className="space-y-0.5">
<AdministratorAccounts administrators={snapshot.administrators} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} />
<IdentityDatabasePanel />
</div>
);
} else if (selected === 'configuration') {
} else if (activeSection === 'configuration') {
content = <ConfigurationEditor snapshot={snapshot} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} onReload={loadSnapshot} />;
} else {
content = <AdminOverview snapshot={snapshot} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} />;
@@ -161,20 +151,18 @@ export default function AdminApp() {
<p>{snapshot ? `Active configuration revision ${snapshot.configuration.revision}.${snapshot.restartRequired ? ' An application restart is required to apply saved changes.' : ' The running application has loaded this revision.'}` : 'Central server administration and configuration.'}</p>
</CardFrame>
{isAdmin ? (
<select className="field-input my-0.5 w-full lg:hidden" value={navigationOptions.some((entry) => entry.key === selected) ? selected : 'overview'} onChange={(event) => selectSection(event.target.value)}>
{navigationOptions.map((entry) => <option key={entry.key} value={entry.key}>{entry.label}</option>)}
</select>
) : null}
<div className="mt-0.5 grid gap-0.5 lg:grid-cols-[15rem_minmax(0,1fr)]">
{isAdmin ? (
<nav className="hidden space-y-0.5 lg:block" aria-label="Administration sections">
{TOP_LEVEL_SECTIONS.filter((entry) => !entry.lockdownOnly || isLockdown).map((entry) => (
<NavigationButton key={entry.key} active={selected === entry.key} onClick={() => selectSection(entry.key)}>{entry.label}</NavigationButton>
))}
<Tabs currentTab={activeSection} onTabChange={selectSection}>
{/* Reusing the same responsive tab surface as the driver page makes
administration feel like another MultiRover workspace instead
of a separate desktop-oriented application. */}
<nav aria-label="Administration sections">
<TabList className="my-0.5">
{navigationOptions.map((entry) => <Tab key={entry.key} id={entry.key}>{entry.label}</Tab>)}
</TabList>
</nav>
) : null}
<section className="min-w-0">{content}</section>
</div>
<TabPanels><section className="min-w-0">{content}</section></TabPanels>
</Tabs>
) : <section className="mt-0.5 min-w-0">{content}</section>}
</main>
</div>
);
@@ -2,6 +2,7 @@
// Purpose: Connects the one hierarchical configuration form to revision, secret, validation, and save behavior.
// Scope: Edits and saves one complete configuration document as one immutable revision.
import { useEffect, useMemo, useState } from 'react';
import CardFrame from '../../components/CardFrame/index.jsx';
import { updateConfiguration } from '../api.js';
import SchemaConfigurationForm from './SchemaConfigurationForm.jsx';
@@ -63,12 +64,12 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
return (
<div className="space-y-0.5">
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur">
<div>
<p className="font-semibold text-slate-100">Configuration</p>
<p className="text-[0.7rem] text-slate-400">Editing the complete revision {revision}. Saved changes apply after an application restart.</p>
</div>
<div className="flex gap-0.5">
<CardFrame className="sticky top-0 z-20 bg-neutral-900/95 backdrop-blur" title="Configuration" meta={`revision ${revision}`} bodyClassName="space-y-0.5 p-0.5">
<p className="text-[0.7rem] text-slate-400">Saved changes apply after an application restart.</p>
{/* All document actions stay together at the start of the toolbar. The
editor may use a wide canvas, but width is never used to separate a
control from the content that explains it. */}
<div className="flex flex-wrap gap-0.5">
<button type="button" className="button-dark" disabled={saving} onClick={onReload}>Reload</button>
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={() => {
setDraft(clone(serverValue));
@@ -76,7 +77,7 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
}}>Reset</button>
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={save}>{saving ? 'Saving…' : 'Save configuration'}</button>
</div>
</div>
</CardFrame>
{error ? <p className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">{error}</p> : null}
{validationErrors.length ? (
<div className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">
@@ -1,9 +1,10 @@
// Schema-Generated Configuration Form
// Purpose: Renders the server-provided JSON Schema as one hierarchical form without feature-specific React components.
// Scope: Adds only the generic secret interaction that ordinary JSON Schema form controls cannot safely infer.
// Purpose: Renders the server-provided JSON Schema in the same cards, surfaces, fields, and buttons as the rest of MultiRover.
// Scope: Defines one generic RJSF presentation; it never names or special-cases an individual service or setting.
import { useMemo } from 'react';
import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import CardFrame from '../../components/CardFrame/index.jsx';
function buildUiSchema(schema, path = '') {
/*
@@ -41,7 +42,9 @@ function SecretWidget({ id, disabled, readonly, options, registry }) {
return (
<div className="surface space-y-0.5 p-0.5">
<div className="flex flex-wrap items-center justify-between gap-0.5">
{/* Secret actions stay beside their status. A wide configuration card
must not turn related controls into a trip across the screen. */}
<div className="flex flex-wrap items-center gap-0.5">
<span className="text-[0.7rem] text-slate-500">
{clearing ? 'Will be cleared when saved' : replacing ? 'Replacement pending' : configured ? 'Configured' : 'Not configured'}
</span>
@@ -70,9 +73,143 @@ function SecretWidget({ id, disabled, readonly, options, registry }) {
);
}
function ConfigurationFieldTemplate({
children,
classNames,
description,
displayLabel,
errors,
help,
hidden,
id,
label,
required,
schema,
style,
}) {
if (hidden) return <div className="hidden">{children}</div>;
const containerField = schema?.type === 'object' || schema?.type === 'array';
if (containerField) {
// Objects and arrays own their visible boundaries and headings in the
// templates below. Keeping this wrapper structural avoids duplicated
// titles while retaining RJSF's useful field-type classes for layout.
return <div className={classNames} style={style}>{children}{errors}{help}</div>;
}
return (
<div className={`${classNames || ''} configuration-field surface p-1`} style={style}>
{displayLabel ? (
<label htmlFor={id} className="block text-xs font-semibold text-slate-100">
{label}{required ? <span className="ml-0.25 text-sky-300">*</span> : null}
</label>
) : null}
{displayLabel && description ? <div className="mt-0.25">{description}</div> : null}
<div className={displayLabel ? 'mt-0.5' : ''}>{children}</div>
{errors}
{help}
</div>
);
}
function ConfigurationObjectTemplate({ description, fieldPathId, properties, title }) {
const visibleProperties = properties.filter((property) => !property.hidden);
const propertyGrid = (
<div className="configuration-property-grid grid gap-0.5 md:grid-cols-2 xl:grid-cols-3">
{/* RJSF gives each property content its own keyed field wrapper. Rendering
it directly preserves field-object and field-array on the grid child,
allowing containers to span the row without another frontend schema. */}
{visibleProperties.map((property) => property.content)}
</div>
);
if (fieldPathId.path.length === 0) {
// The editor toolbar already identifies the root document. The root is a
// simple ordered stack so every service-owned top-level object receives
// the full page width before arranging its own fields responsively.
return <div className="space-y-1">{visibleProperties.map((property) => property.content)}</div>;
}
if (typeof fieldPathId.path.at(-1) === 'number') {
// Array items receive their numbered heading and action row from the array
// item template. Rendering only their property grid prevents redundant
// nested boxes such as "Item 1" followed by another anonymous object box.
return propertyGrid;
}
if (fieldPathId.path.length === 1) {
return (
<CardFrame title={title} clipOverflow={false} bodyClassName="space-y-0.5 p-0.5">
{description ? <div className="px-0.5 text-xs text-slate-400">{description}</div> : null}
{propertyGrid}
</CardFrame>
);
}
return (
<section className="configuration-object surface border border-neutral-500/60 p-0.5">
<h3 className="mb-0.5 text-sm font-semibold text-slate-100">{title}</h3>
{description ? <div className="mb-0.5 text-xs text-slate-400">{description}</div> : null}
{propertyGrid}
</section>
);
}
function ConfigurationArrayItemTemplate({ buttonsProps, children, hasToolbar, index }) {
const unavailable = buttonsProps.disabled || buttonsProps.readonly;
return (
<article className="surface-muted space-y-0.5 border border-neutral-500/60 p-0.5">
{hasToolbar ? (
// Text controls are intentionally kept immediately after the item
// number. RJSF's default Bootstrap toolbox pushes empty glyphicon
// buttons to the far edge, which is both unclear and hard to reach.
<header className="flex flex-wrap items-center gap-0.5">
<span className="mr-0.5 text-xs font-semibold text-slate-100">Item {index + 1}</span>
{(buttonsProps.hasMoveUp || buttonsProps.hasMoveDown) ? (
<button type="button" className="button-dark text-xs" disabled={unavailable || !buttonsProps.hasMoveUp} onClick={buttonsProps.onMoveUpItem}>Move up</button>
) : null}
{(buttonsProps.hasMoveUp || buttonsProps.hasMoveDown) ? (
<button type="button" className="button-dark text-xs" disabled={unavailable || !buttonsProps.hasMoveDown} onClick={buttonsProps.onMoveDownItem}>Move down</button>
) : null}
{buttonsProps.hasCopy ? (
<button type="button" className="button-dark text-xs" disabled={unavailable} onClick={buttonsProps.onCopyItem}>Duplicate</button>
) : null}
{buttonsProps.hasRemove ? (
<button type="button" className="button-danger text-xs" disabled={unavailable} onClick={buttonsProps.onRemoveItem}>Remove</button>
) : null}
</header>
) : null}
{children}
</article>
);
}
function ConfigurationArrayTemplate({ canAdd, disabled, items, onAddClick, readonly, schema, title }) {
return (
<section className="configuration-array surface space-y-0.5 border border-neutral-500/60 p-0.5">
<header className="flex flex-wrap items-center gap-0.5">
<h3 className="text-sm font-semibold text-slate-100">{title}</h3>
<span className="text-[0.7rem] text-slate-400">{items.length} {items.length === 1 ? 'item' : 'items'}</span>
</header>
{schema.description ? <div className="text-xs text-slate-400">{schema.description}</div> : null}
{items.length ? <div className="space-y-0.5">{items}</div> : <p className="text-xs text-slate-500">No items configured.</p>}
{canAdd ? (
<button type="button" className="button-dark text-xs" disabled={disabled || readonly} onClick={onAddClick}>Add item</button>
) : null}
</section>
);
}
export default function SchemaConfigurationForm({ schema, value, onChange, configuredSecrets, secretOperations, setSecretOperation }) {
const uiSchema = useMemo(() => buildUiSchema(schema), [schema]);
const widgets = useMemo(() => ({ SecretWidget }), []);
const templates = useMemo(() => ({
ArrayFieldItemTemplate: ConfigurationArrayItemTemplate,
ArrayFieldTemplate: ConfigurationArrayTemplate,
FieldTemplate: ConfigurationFieldTemplate,
ObjectFieldTemplate: ConfigurationObjectTemplate,
}), []);
const formContext = useMemo(() => ({
configuredSecrets,
secretOperations,
@@ -87,6 +224,7 @@ export default function SchemaConfigurationForm({ schema, value, onChange, confi
formData={value}
validator={validator}
widgets={widgets}
templates={templates}
formContext={formContext}
noHtml5Validate
showErrorList={false}
+13 -34
View File
@@ -66,36 +66,9 @@ body::-webkit-scrollbar,
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-rose-600 hover:bg-rose-500 rounded-md;
}
/*
The configuration editor is generated by one JSON Schema renderer. These
rules style its standard semantic HTML once; individual services never
need React components or UI-specific class names for their settings.
*/
.configuration-schema-form form > fieldset {
@apply space-y-0.5 border-0 p-0;
}
.configuration-schema-form fieldset fieldset {
@apply my-0.5 space-y-0.5 border-l-2 border-sky-800/70 bg-neutral-900/70 py-0.5 pl-1 pr-0.5;
}
.configuration-schema-form legend,
.configuration-schema-form h1,
.configuration-schema-form h2,
.configuration-schema-form h3,
.configuration-schema-form h4,
.configuration-schema-form h5 {
@apply mb-0.5 font-mono text-sm font-semibold text-sky-300;
}
.configuration-schema-form .form-group {
@apply mb-0.5;
}
.configuration-schema-form label {
@apply mb-0.5 block text-xs font-semibold text-slate-200;
}
/* RJSF still owns values, validation, and schema traversal, while the
generic React templates own layout. Only native form-control appearance
remains here, shared uniformly by every present and future setting. */
.configuration-schema-form input:not([type='checkbox']),
.configuration-schema-form select,
.configuration-schema-form textarea {
@@ -103,7 +76,11 @@ body::-webkit-scrollbar,
}
.configuration-schema-form input[type='checkbox'] {
@apply mr-0.5 h-4 w-4 accent-sky-500;
@apply mr-0.5 h-4 w-4 align-middle accent-sky-500;
}
.configuration-schema-form .checkbox label {
@apply flex cursor-pointer items-center gap-0.5 text-sm font-semibold text-slate-100;
}
.configuration-schema-form .field-description,
@@ -115,8 +92,10 @@ body::-webkit-scrollbar,
@apply mt-0.5 text-xs text-red-300;
}
.configuration-schema-form button:not(.button-dark):not(.button-danger) {
@apply mx-0.5 rounded-md border border-sky-300 bg-sky-600 px-0.5 py-0.5 text-xs font-medium text-white transition-colors hover:border-sky-500 hover:bg-sky-500;
/* Container fields need the complete row in responsive property grids.
Primitive fields remain free to share columns where their parent has room. */
.configuration-property-grid > .field-object,
.configuration-property-grid > .field-array {
@apply col-span-full;
}
}