This commit is contained in:
legop3
2026-09-14 12:15:59 -04:00
parent 3d2e75572f
commit 4635e1b40c
18 changed files with 197 additions and 149 deletions
+2 -2
View File
@@ -20,7 +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: Render the schema-driven configuration editor as a YAML-like tree inside one `CardFrame`. Every object or array introduces an ordered header and one indentation guide, every scalar occupies one key/value row, and array operations remain beside their item instead of moving to the far edge. Keep all route-specific RJSF styling in `webui/src/admin/styles.css`, outside the shared global stylesheet.
- 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.
@@ -442,7 +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.
- Replaced RJSF's unthemed Bootstrap markup with a generic MultiRover tree renderer. The complete document now follows schema order as indented object, array, item, and key/value rows; array controls remain readable text beside each item, and the route-specific styling lives outside the global stylesheet.
- 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
@@ -0,0 +1 @@
.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-branch{min-width:0px}.configuration-branch-heading,.configuration-item-heading{display:flex;flex-wrap:wrap;align-items:center;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity));padding:.125rem}.configuration-branch-heading h3,.configuration-item-title{font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-branch-heading>div,.configuration-branch-description{font-size:.7rem;--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-children{margin-left:.25rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-children{border-left-width:1px;border-color:#0369a1b3;padding-top:.125rem;padding-bottom:.125rem;padding-left:.25rem}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-key .field-description,.configuration-key .help-block,.configuration-branch-description{display:block;font-size:.7rem;line-height:1.25;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity));margin-top:.125rem}.configuration-value input:not([type=checkbox]),.configuration-value select,.configuration-value textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity));padding:.125rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::-moz-placeholder,.configuration-value select::-moz-placeholder,.configuration-value textarea::-moz-placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::placeholder,.configuration-value select::placeholder,.configuration-value textarea::placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox]):focus,.configuration-value select:focus,.configuration-value textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(14 165 233 / var(--tw-ring-opacity))}.configuration-value input[type=checkbox]{height:1rem;width:1rem;vertical-align:middle;accent-color:#0ea5e9}.configuration-value .checkbox label{display:flex;min-height:1.75rem;align-items:center;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-value .error-detail{margin-top:.125rem;font-size:.75rem;line-height:1rem;--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity))}.configuration-value .help-block{margin-top:.125rem;display:block;font-size:.7rem;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity))}.configuration-secret{min-width:0px}.configuration-array-item+.configuration-array-item{margin-top:.125rem}
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-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
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-DNzOwgk-.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-CgxV6A-m.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-DA_yf5GI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DAc-5H0K.css">
<script type="module" crossorigin src="/assets/index-DNzOwgk-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1O6avznD.css">
</head>
<body>
<div id="root"></div>
+1
View File
@@ -19,6 +19,7 @@ import AdministratorAccounts from './components/AdministratorAccounts.jsx';
import AdminOverview from './components/AdminOverview.jsx';
import ConfigurationEditor from './components/ConfigurationEditor.jsx';
import PasswordConfirmationDialog from './components/PasswordConfirmationDialog.jsx';
import './styles.css';
const TOP_LEVEL_SECTIONS = [
{ key: 'overview', label: 'Overview' },
@@ -63,9 +63,9 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
}
return (
<div className="space-y-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>
<CardFrame title="Configuration" meta={`revision ${revision}`} clipOverflow={false} bodyClassName="p-0.5">
<div className="configuration-toolbar sticky top-0 z-20 mb-0.5 space-y-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur">
<p className="text-xs 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. */}
@@ -77,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>
</CardFrame>
</div>
{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">
@@ -97,6 +97,6 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
secretOperations={secretOperations}
setSecretOperation={setSecretOperation}
/>
</div>
</CardFrame>
);
}
@@ -1,10 +1,9 @@
// Schema-Generated Configuration Form
// Purpose: Renders the server-provided JSON Schema in the same cards, surfaces, fields, and buttons as the rest of MultiRover.
// Purpose: Renders the server-provided JSON Schema as an ordered, indented tree resembling the configuration's YAML structure.
// 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 +40,7 @@ function SecretWidget({ id, disabled, readonly, options, registry }) {
}
return (
<div className="surface space-y-0.5 p-0.5">
<div className="configuration-secret space-y-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">
@@ -77,7 +76,6 @@ function ConfigurationFieldTemplate({
children,
classNames,
description,
displayLabel,
errors,
help,
hidden,
@@ -98,59 +96,48 @@ function ConfigurationFieldTemplate({
}
return (
<div className={`${classNames || ''} configuration-field surface p-1`} style={style}>
{displayLabel ? (
<label htmlFor={id} className="block text-xs font-semibold text-slate-100">
<div className={`${classNames || ''} configuration-line`} style={style}>
<div className="configuration-key">
{/* Boolean widgets deliberately hide their internal duplicate label, so
every scalar can use this same key column and preserve YAML order. */}
<label htmlFor={id} className="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}
{description ? <div>{description}</div> : null}
</div>
<div className="configuration-value">
{children}
{errors}
{help}
</div>
</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>
);
const propertyLines = visibleProperties.map((property) => property.content);
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>;
// The outer configuration card already names the root document. Rendering
// its properties directly makes their schema order read like YAML lines.
return <div className="configuration-tree">{propertyLines}</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
// item template. Rendering only their ordered property lines 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 <div>{propertyLines}</div>;
}
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 className="configuration-branch">
<header className="configuration-branch-heading">
<h3>{title}</h3>
{description ? <div>{description}</div> : null}
</header>
<div className="configuration-children">{propertyLines}</div>
</section>
);
}
@@ -159,13 +146,13 @@ function ConfigurationArrayItemTemplate({ buttonsProps, children, hasToolbar, in
const unavailable = buttonsProps.disabled || buttonsProps.readonly;
return (
<article className="surface-muted space-y-0.5 border border-neutral-500/60 p-0.5">
<article className="configuration-branch configuration-array-item">
{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>
<header className="configuration-item-heading">
<span className="configuration-item-title">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}
@@ -180,23 +167,25 @@ function ConfigurationArrayItemTemplate({ buttonsProps, children, hasToolbar, in
) : null}
</header>
) : null}
{children}
<div className="configuration-children">{children}</div>
</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>
<section className="configuration-branch configuration-array">
<header className="configuration-branch-heading configuration-array-heading">
<h3>{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}
{schema.description ? <div className="configuration-branch-description">{schema.description}</div> : null}
<div className="configuration-children">
{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 mt-0.5 text-xs" disabled={disabled || readonly} onClick={onAddClick}>Add item</button>
) : null}
</div>
</section>
);
}
+89
View File
@@ -0,0 +1,89 @@
/* Administration Application Styles
* Purpose: Styles the schema-generated configuration tree used only by /admin.
* Scope: Keeps route-specific hierarchy and RJSF integration out of the shared global stylesheet.
*/
.configuration-tree {
/* A single ordered stack is the visual equivalent of YAML's top-to-bottom
document order. Separation comes from hierarchy, not unrelated tiles. */
@apply space-y-0.5;
}
.configuration-branch {
@apply min-w-0;
}
.configuration-branch-heading,
.configuration-item-heading {
/* Headings and their controls use normal left-to-right flow. Deliberately
omitting space-between prevents wide pages from marooning actions. */
@apply flex flex-wrap items-center gap-0.5 rounded-md bg-slate-800 px-0.5 py-0.5;
}
.configuration-branch-heading h3,
.configuration-item-title {
@apply text-sm font-semibold text-slate-100;
}
.configuration-branch-heading > div,
.configuration-branch-description {
@apply text-[0.7rem] text-slate-400;
}
.configuration-children {
/* Every container contributes exactly one indentation and one guide. Deep
descendants therefore explain their ancestry without calculating depth or
encoding service paths in frontend code. */
@apply ml-1 space-y-0.5 border-l border-sky-700/70 py-0.5 pl-1;
}
.configuration-line {
/* Each scalar is one logical YAML-like line. The bounded value column starts
immediately after its key instead of stretching to the viewport edge. */
@apply grid min-w-0 grid-cols-1 items-start gap-0.5 rounded-md bg-neutral-800 px-0.5 py-0.5 sm:grid-cols-[minmax(9rem,16rem)_minmax(12rem,40rem)];
justify-content: start;
}
.configuration-key,
.configuration-value {
@apply min-w-0;
}
.configuration-key .field-description,
.configuration-key .help-block,
.configuration-branch-description {
@apply block text-[0.7rem] leading-tight text-slate-500;
margin-top: 0.125rem;
}
.configuration-value input:not([type='checkbox']),
.configuration-value select,
.configuration-value textarea {
@apply w-full rounded-md border border-neutral-600 bg-neutral-700 px-0.5 py-0.5 text-white placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-sky-500;
}
.configuration-value input[type='checkbox'] {
@apply h-4 w-4 align-middle accent-sky-500;
}
.configuration-value .checkbox label {
/* RJSF hides its duplicate boolean label because the key column already
names the setting; this rule keeps the remaining checkbox compact. */
@apply flex min-h-7 items-center text-slate-100;
}
.configuration-value .error-detail {
@apply mt-0.5 text-xs text-red-300;
}
.configuration-value .help-block {
@apply mt-0.5 block text-[0.7rem] text-slate-500;
}
.configuration-secret {
@apply min-w-0;
}
.configuration-array-item + .configuration-array-item {
@apply mt-0.5;
}
-32
View File
@@ -66,36 +66,4 @@ 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;
}
/* 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 {
@apply w-full rounded-md border border-neutral-600 bg-neutral-700 px-0.5 py-0.5 text-white placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-sky-500;
}
.configuration-schema-form input[type='checkbox'] {
@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,
.configuration-schema-form .help-block {
@apply mt-0.5 block text-[0.7rem] text-slate-500;
}
.configuration-schema-form .error-detail {
@apply mt-0.5 text-xs text-red-300;
}
/* 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;
}
}