Compare commits

...
8 Commits
Author SHA1 Message Date
legop3 881583ee0a yaml importer slopping it up 2026-09-14 13:48:58 -04:00
legop3 8ff3c39765 better colorses 2026-09-14 13:30:59 -04:00
legop3 9b5c187aac cardframing again 2026-09-14 13:27:48 -04:00
legop3 0266bd9568 leg 2026-09-14 13:04:41 -04:00
legop3 6edb6f6dd0 old defaults in new schema 2026-09-14 12:54:29 -04:00
legop3 ec8eb1c002 cardframing 2026-09-14 12:41:25 -04:00
legop3 3c385126ae descriptionslop 2026-09-14 12:34:22 -04:00
legop3 4635e1b40c slorp 2026-09-14 12:15:59 -04:00
45 changed files with 961 additions and 371 deletions
+12 -5
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.
@@ -213,7 +213,7 @@ The setup upload must:
- Preserve lockdown roles and Discord IDs.
- Preserve secrets without printing them.
- Apply current defaults for absent fields.
- Report unknown or invalid fields instead of discarding them.
- Ignore fields that do not exist in the current schema, while reporting invalid values supplied for current fields.
- Validate the entire result before writing anything.
- Refuse to replace an already-configured database.
- Write the configuration, administrators, and audit event atomically.
@@ -389,7 +389,7 @@ Phase 1 is complete only when all of the following are true:
- The current systemd installation runs without `config.yaml`.
- A completely empty data directory can be initialized through `/setup`.
- An explicitly selected YAML file can initialize the empty database exactly once.
- The setup upload reports unknown or invalid values instead of discarding them.
- The setup upload ignores nonexistent fields and reports invalid values supplied for current fields.
- Startup and installation do not search for or modify an old `config.yaml`.
- All mutable server state is contained by the configured data directory.
- A complete backup can be downloaded and validated.
@@ -439,10 +439,17 @@ Implemented on 2026-09-14:
- Redacted secrets from browser responses and audit data. The one complete save operation preserves stored secrets unless the administrator explicitly replaces or clears them.
- Converted every runtime configuration consumer to the synchronous database-backed configuration service and removed the YAML loader, `SERVER_CONFIG`, and the tracked example YAML.
- Added an explicit one-time YAML upload to `/setup`. Existing bcrypt hashes, lockdown roles, Discord identities, configuration, and secrets can be imported only when the operator selects the file; the installer and startup perform no automatic discovery or migration, and there is no command-line importer.
- Made setup-file import recursively retain only fields present in the current schema. Stale keys from the permissive YAML era are ignored without aliases or historical translations, while invalid values for real current settings still fail validation; stream-only and snapshot-only room-camera entries remain accepted as they were by the runtime.
- 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.
- Replaced the editor's custom section borders, header backgrounds, and indentation guides with the application's shared `CardFrame` at every object, array, and array-item layer. Scalar settings remain compact key/value rows, descriptions use the wider value column, and collection actions stay beside their content instead of moving to the far edge.
- Disabled RJSF's internal checkbox label and description generically, leaving the shared field row as the single owner of each boolean setting's name, required marker, and description.
- Restored the former example YAML's installation-specific values as both schema-owned input examples and the actual initial values for non-secret settings and collection shapes. The only intentionally empty defaults are the three credentials and active driver HTML; their placeholders still explain the expected input without falsely marking credentials as configured or publishing sample content.
- Strengthened top-level hierarchy with a 1.5-rem sibling gap while retaining compact spacing within each configuration section.
- Extended `CardFrame` with an optional explicit accent while preserving its assigned-rover default, then gave every configuration nesting level its own complete header-and-border accent. Nested CardFrames themselves now carry the YAML-like indentation, scalar contents remain aligned with their owning card, and descriptions use a larger, higher-contrast treatment.
- Traced all 156 schema nodes to their runtime consumers and added operator-facing descriptions for every root, section, collection, array item, and scalar option. A recursive configuration test now rejects any future schema node without a description; currently reserved settings explicitly state that they have no runtime effect.
- 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.
@@ -450,7 +457,7 @@ Implemented on 2026-09-14:
Local verification completed:
- All 103 server tests passed, including file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, explicit setup-file import, and the earlier filesystem coverage.
- All 107 server tests passed, including populated legacy-style default coverage, complete schema-description and input-example coverage, file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, explicit setup-file import with recursive removal of nonexistent fields, and the earlier filesystem coverage.
- Focused admin, route, and identity UI lint passed.
- All 20 existing focused web UI tests passed.
- The production web UI build completed successfully and regenerated the checked-in server assets.
@@ -0,0 +1 @@
.configuration-tree{margin-top:.25rem}.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.configuration-card{min-width:0px}.configuration-card .configuration-card{margin-left:1rem;width:calc(100% - 1rem)}.configuration-card-body>: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-card-body{padding:.125rem}.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-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-root-description,.configuration-branch-description,.configuration-item-description,.configuration-value-description{display:block;font-size:.875rem;line-height:1.25rem;line-height:1.375;--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity));margin-top:.125rem}.configuration-root-description{margin-bottom:.25rem}.configuration-branch-description,.configuration-item-description{max-width:56rem}.configuration-value-description{margin-bottom:.125rem;max-width:40rem}.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-item-actions,.configuration-array-actions{display:flex;flex-wrap:wrap;gap:.125rem}
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
@@ -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-CqMkCfQw.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-C2cP8ApY.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
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-CqMkCfQw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1O6avznD.css">
</head>
<body>
<div id="root"></div>
+165 -2
View File
@@ -20,6 +20,77 @@ function createTestDatabase() {
return createConfigurationDatabase({ databasePath: path.join(root, 'configuration.sqlite') });
}
function collectUndocumentedSchemaPaths(schema, pathLabel = '$') {
/*
The admin editor is entirely schema-generated, so missing schema prose is
missing operator documentation. Walk objects, arrays, array item schemas,
and scalar leaves instead of checking only named service definitions; this
makes every visible level of the hierarchy uphold the same contract.
*/
if (!schema || typeof schema !== 'object') return [];
const missing = typeof schema.description === 'string' && schema.description.trim()
? []
: [pathLabel];
if (schema.properties) {
Object.entries(schema.properties).forEach(([key, childSchema]) => {
missing.push(...collectUndocumentedSchemaPaths(childSchema, `${pathLabel}.${key}`));
});
}
if (schema.items) {
missing.push(...collectUndocumentedSchemaPaths(schema.items, `${pathLabel}[]`));
}
return missing;
}
function collectSchemaPathsMissingInputExamples(schema, value, pathLabel = '$', insideArray = false) {
/*
Universal defaults such as timeouts and modes are real saved values. Empty
strings and newly-created array items are different: they require an
installation-specific value, so the admin form must show an example without
persisting a fake hostname, credential, or hardware ID. This walk enforces
that distinction across both the current default document and array shapes.
*/
if (!schema || typeof schema !== 'object') return [];
if (schema.type === 'array') {
return collectSchemaPathsMissingInputExamples(schema.items, undefined, `${pathLabel}[]`, true);
}
if (schema.type === 'object') {
return Object.entries(schema.properties || {}).flatMap(([key, childSchema]) => (
collectSchemaPathsMissingInputExamples(childSchema, value?.[key], `${pathLabel}.${key}`, insideArray)
));
}
// Enumerations and checkboxes already communicate their accepted shape
// through their controls, so placeholder examples are only required for
// otherwise free-form empty scalar inputs.
const needsExample = (value === '' || insideArray)
&& !Array.isArray(schema.enum)
&& schema.type !== 'boolean';
if (!needsExample) return [];
return Array.isArray(schema.examples) && schema.examples.length ? [] : [pathLabel];
}
function collectEmptyStringPaths(value, pathLabel = '$') {
/*
Empty-string policy is intentionally tested by path because these four
fields are exceptional for security or visible behavior, not omissions in
the legacy-style default document. Walking the complete value also catches
an accidentally blank field inside a pre-populated example collection.
*/
if (Array.isArray(value)) {
return value.flatMap((item, index) => collectEmptyStringPaths(item, `${pathLabel}[${index}]`));
}
if (value && typeof value === 'object') {
return Object.entries(value).flatMap(([key, childValue]) => (
collectEmptyStringPaths(childValue, `${pathLabel}.${key}`)
));
}
return value === '' ? [pathLabel] : [];
}
test.after(() => {
temporaryRoots.forEach((root) => fs.rmSync(root, { recursive: true, force: true }));
});
@@ -32,6 +103,25 @@ test('safe defaults form a complete valid configuration with integrations disabl
assert.equal(defaultConfig.balanceBoard.enabled, false);
});
test('legacy-style defaults populate every non-secret and inactive-content value', () => {
/*
Credentials must not masquerade as configured, and driver HTML would be
immediately visible without an enable switch. Every other free-form value
should match the populated template behavior operators had with YAML.
*/
assert.deepEqual(collectEmptyStringPaths(defaultConfig), [
'$.homeAssistant.token',
'$.ptzCamera.password',
'$.discord.token',
'$.driverAd.html',
]);
assert.ok(defaultConfig.interInstance.directoryUrls.length > 0);
assert.ok(defaultConfig.homeAssistant.entities.length > 0);
assert.ok(defaultConfig.homeAssistant.buttons.length > 0);
assert.ok(defaultConfig.roomCameras.cameras.length > 0);
assert.ok(defaultConfig.socials.links.length > 0);
});
test('service definitions determine document order and write-only secret handling', () => {
/*
The generic browser form and backend persistence both consume this one
@@ -46,6 +136,24 @@ test('service definitions determine document order and write-only secret handlin
assert.equal(rootSchema.properties.discord.properties.token.writeOnly, true);
});
test('every configuration section, collection, item, and option has an operator description', () => {
/*
New configuration remains self-documenting by default. Reporting every
dotted path in one assertion gives a contributor an exact repair list and
avoids recreating a separately maintained documentation registry.
*/
assert.deepEqual(collectUndocumentedSchemaPaths(rootSchema), []);
});
test('empty installation-specific fields and array item inputs provide schema-owned examples', () => {
/*
The frontend derives placeholders from these examples generically. Keeping
this assertion beside schema composition prevents an empty, unexplained box
from returning when a service adds configuration in the future.
*/
assert.deepEqual(collectSchemaPathsMissingInputExamples(rootSchema, defaultConfig), []);
});
test('service definitions generate public feature paths without a separate registry', () => {
/*
This order follows the one configuration document, including nested Neato
@@ -103,7 +211,10 @@ test('generated feature flags use only each declared enabled switch', () => {
test('normalization fills missing legacy fields but strict validation rejects unknown fields', () => {
const normalized = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video' } });
assert.deepEqual(normalized.media.additionalHosts, []);
// Missing fields now receive the same populated template defaults as a new
// installation; normalization must not silently revert this one collection
// to the former empty-safe-default policy.
assert.deepEqual(normalized.media.additionalHosts, ['rover.example.com', 'media-server.local']);
assert.doesNotThrow(() => assertValidConfig(normalized));
const invalid = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video', misspelledHost: 'x' } });
@@ -168,7 +279,7 @@ test('administrator storage never exposes hashes or removes the final lockdown a
database.close();
});
test('an explicitly uploaded YAML file imports configuration and bcrypt hashes exactly once', () => {
test('an explicitly uploaded YAML imports current fields, ignores obsolete keys, and preserves bcrypt hashes exactly once', () => {
const yamlText = `
admins:
- username: owner
@@ -178,10 +289,45 @@ admins:
timezone: America/Chicago
media:
whepBaseUrl: http://localhost:8889/video
overseerControl:
enabled: false
heartbeatMs: 30000
alwaysRunModel: false
homeAssistant:
neato:
enabled: false
brainslugHost: neato-vacuum.local
brainslugKey: retired-secret
brainslugLogFile: /tmp/retired.log
roomCameras:
enabled: true
cameras:
- id: stream-only
name: Stream-only camera
streamUrl: http://camera.local/stream.mjpg
discord:
channels:
chatBridge: "123456789012345678"
roles:
stalker: "123456789012345678"
fleetReports:
discord:
immediateCriticalAlerts: true
`;
const parsed = parseConfigurationFile(yamlText);
assert.equal(parsed.config.timezone, 'America/Chicago');
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'heartbeatMs'), false);
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'alwaysRunModel'), false);
assert.equal(Object.hasOwn(parsed.config.homeAssistant.neato, 'brainslugHost'), false);
assert.equal(Object.hasOwn(parsed.config.discord.channels, 'chatBridge'), false);
assert.equal(Object.hasOwn(parsed.config.discord.roles, 'stalker'), false);
assert.equal(Object.hasOwn(parsed.config.fleetReports.discord, 'immediateCriticalAlerts'), false);
assert.deepEqual(parsed.config.roomCameras.cameras, [{
id: 'stream-only',
name: 'Stream-only camera',
streamUrl: 'http://camera.local/stream.mjpg',
}]);
const database = createTestDatabase();
const result = importConfigurationFile({ text: yamlText, database });
@@ -190,3 +336,20 @@ media:
assert.throws(() => importConfigurationFile({ text: yamlText, database }), /cannot replace an initialized installation/);
database.close();
});
test('uploaded YAML still rejects invalid values for fields in the current schema', () => {
const yamlText = `
admins:
- username: owner
password_hash: "$2b$10$preservedHash"
lockdown: true
bandwidthSavings:
multiTabProtection: unsupported-mode
`;
assert.throws(() => parseConfigurationFile(yamlText), (error) => {
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
assert.ok(error.validationErrors.some((entry) => entry.path === '/bandwidthSavings/multiTabProtection'));
return true;
});
});
@@ -2,7 +2,32 @@
// Purpose: Validates one YAML file deliberately uploaded during first-run setup and stores it in the configuration database.
// Scope: This is an explicit setup action only; startup and installation never search for or consume configuration files.
const yaml = require('js-yaml');
const { normalizeConfig, assertValidConfig } = require('./validation');
const { rootSchema, normalizeConfig, assertValidConfig } = require('./validation');
function keepCurrentSchemaFields(value, schema) {
/*
An uploaded file is only a convenient seed for the current configuration;
it is not a second schema or a historical migration framework. Legacy YAML
was permissive, so real installations naturally contain keys left behind
by removed features. At object boundaries, copy only properties that exist
in today's schema and recursively apply the same rule to nested objects and
array items. Known fields retain their original values and are validated
normally afterward, so this cannot hide a malformed current setting.
*/
if (schema?.type === 'object') {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
return Object.fromEntries(Object.entries(schema.properties || {})
.filter(([key]) => Object.hasOwn(value, key))
.map(([key, childSchema]) => [key, keepCurrentSchemaFields(value[key], childSchema)]));
}
if (schema?.type === 'array') {
if (!Array.isArray(value)) return value;
return value.map((item) => keepCurrentSchemaFields(item, schema.items));
}
return value;
}
function normalizeUploadedAdministrator(entry, index) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
@@ -33,13 +58,11 @@ function parseConfigurationFile(text) {
const configInput = Object.fromEntries(
Object.entries(parsed).filter(([key]) => key !== 'admins'),
);
const config = normalizeConfig(configInput);
const config = normalizeConfig(keepCurrentSchemaFields(configInput, rootSchema));
/*
Unknown fields remain in the normalized document so strict schema
validation reports them to the operator instead of silently losing data
from the explicitly selected file.
*/
// Filtering applies only to nonexistent keys. Values retained for current
// schema fields still have to satisfy every type, range, and format rule
// before the importer can atomically initialize the database.
assertValidConfig(config);
if (!administrators.some((admin) => admin.role === 'lockdown')) {
throw new Error('The configuration file must contain at least one lockdown administrator.');
+1
View File
@@ -61,6 +61,7 @@ const properties = Object.fromEntries(
);
const rootSchema = strictObject(properties, {
title: 'Configuration',
description: 'Complete server configuration. Changes are validated and saved as one revision, then loaded when the application restarts.',
required: definitions.map(({ key }) => key),
});
@@ -13,13 +13,13 @@ module.exports = {
externalSpectatorAccess: 'on',
},
schema: strictObject({
multiTabProtection: string({ enum: ['allowed', 'verifiedOnly', 'notAllowed'] }),
pauseHiddenRoverVideo: boolean(),
multiTabProtection: string({ description: 'Controls multiple active driver tabs: allowed permits everyone, verifiedOnly limits ordinary unverified users, and notAllowed limits all non-admin users.', enum: ['allowed', 'verifiedOnly', 'notAllowed'] }),
pauseHiddenRoverVideo: boolean({ description: 'Stops a rover video player while its browser surface is hidden, reducing unnecessary client and server bandwidth.' }),
nonTurnVideo: strictObject({
mode: string({ enum: ['snapshots', 'live'] }),
userThreshold: integer({ minimum: 0, maximum: 100000 }),
}, { required: ['mode', 'userThreshold'] }),
externalSpectatorVideo: string({ enum: ['snapshots', 'live'] }),
externalSpectatorAccess: string({ enum: ['off', 'on', 'verifiedOnly', 'admin'] }),
}, { title: 'Bandwidth savings', required: ['multiTabProtection', 'pauseHiddenRoverVideo', 'nonTurnVideo', 'externalSpectatorVideo', 'externalSpectatorAccess'] }),
mode: string({ description: 'snapshots replaces non-turn live rover video after the threshold is exceeded; live always permits live video.', enum: ['snapshots', 'live'] }),
userThreshold: integer({ description: 'Maximum controllable-user count allowed before snapshot mode activates for non-turn viewers; zero activates it whenever any controllable user exists.', minimum: 0, maximum: 100000 }),
}, { description: 'Controls whether users who are not currently driving receive live rover video or periodic snapshots.', required: ['mode', 'userThreshold'] }),
externalSpectatorVideo: string({ description: 'Video delivered to non-local spectator pages: snapshots conserves upload bandwidth, while live permits continuous playback.', enum: ['snapshots', 'live'] }),
externalSpectatorAccess: string({ description: 'Access for ordinary non-local spectators: off denies them, on permits them, verifiedOnly requires verification, and admin requires the spectator access grant. Local users and administrators remain allowed.', enum: ['off', 'on', 'verifiedOnly', 'admin'] }),
}, { title: 'Bandwidth savings', description: 'Defines server-owned policies for duplicate driver tabs and when live video is replaced with snapshots.', required: ['multiTabProtection', 'pauseHiddenRoverVideo', 'nonTurnVideo', 'externalSpectatorVideo', 'externalSpectatorAccess'] }),
};
@@ -7,9 +7,9 @@ module.exports = {
key: 'audioForward',
defaultValue: { enabled: true, ffmpegBin: 'ffmpeg', streamSuffix: '-fwd', maxUploadBytes: 8388608 },
schema: strictObject({
enabled: boolean(),
ffmpegBin: string({ title: 'ffmpeg executable', minLength: 1, maxLength: 500 }),
streamSuffix: string({ minLength: 1, maxLength: 80 }),
maxUploadBytes: integer({ minimum: 262144, maximum: 1073741824 }),
}, { title: 'Audio forwarding', required: ['enabled', 'ffmpegBin', 'streamSuffix', 'maxUploadBytes'] }),
enabled: boolean({ description: 'Enables verified current drivers to publish microphone or uploaded audio to their assigned rover.' }),
ffmpegBin: string({ title: 'ffmpeg executable', description: 'Executable name or path used to run the long-lived audio publishing and playback workers.', minLength: 1, maxLength: 500 }),
streamSuffix: string({ description: 'Suffix appended to each rover ID to form its internal MediaMTX forwarded-audio stream path.', minLength: 1, maxLength: 80 }),
maxUploadBytes: integer({ description: 'Maximum accepted size in bytes for one uploaded audio clip.', minimum: 262144, maximum: 1073741824 }),
}, { title: 'Audio forwarding', description: 'Controls browser-to-rover audio publishing, temporary uploaded clips, and the ffmpeg workers that feed MediaMTX.', required: ['enabled', 'ffmpegBin', 'streamSuffix', 'maxUploadBytes'] }),
};
@@ -7,9 +7,9 @@ module.exports = {
key: 'audioLevels',
defaultValue: { hornGain: 1, ttsGain: 1, forwardGain: 1, maxPersonalAdjustmentPercent: 50 },
schema: strictObject({
hornGain: number({ minimum: 0, maximum: 4 }),
ttsGain: number({ title: 'TTS gain', minimum: 0, maximum: 4 }),
forwardGain: number({ minimum: 0, maximum: 4 }),
maxPersonalAdjustmentPercent: integer({ minimum: 0, maximum: 100 }),
}, { title: 'Audio levels', required: ['hornGain', 'ttsGain', 'forwardGain', 'maxPersonalAdjustmentPercent'] }),
hornGain: number({ description: 'Server base multiplier for external horn playback, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
ttsGain: number({ title: 'TTS gain', description: 'Server base multiplier for text-to-speech playback, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
forwardGain: number({ description: 'Server base multiplier for browser-forwarded and uploaded audio, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
maxPersonalAdjustmentPercent: integer({ description: 'Largest positive or negative percentage adjustment permitted for users granted personal audio controls; zero disables personal variation.', minimum: 0, maximum: 100 }),
}, { title: 'Audio levels', description: 'Sets the initial server-owned playback gains and the allowed range for per-user adjustments.', required: ['hornGain', 'ttsGain', 'forwardGain', 'maxPersonalAdjustmentPercent'] }),
};
@@ -7,5 +7,12 @@ module.exports = {
key: 'balanceBoard',
feature: true,
defaultValue: { enabled: false, simulate: false },
schema: strictObject({ enabled: boolean(), simulate: boolean() }, { title: 'Balance Board', required: ['enabled', 'simulate'] }),
schema: strictObject({
enabled: boolean({ description: 'Starts the Wii Balance Board service and exposes its readings and controls after restart.' }),
simulate: boolean({ description: 'Runs the native worker with generated cyclic sensor data instead of connecting to Bluetooth hardware.' }),
}, {
title: 'Balance Board',
description: 'Optional Wii Balance Board input service with a development simulation mode.',
required: ['enabled', 'simulate'],
}),
};
@@ -6,10 +6,12 @@ const { strictObject, string, boolean } = require('../../configuration/schemaHel
module.exports = {
key: 'barcodeGames',
feature: true,
defaultValue: { enabled: false, botName: 'Barcode Games', profileImageUrl: '' },
// Disabled-by-default feature state is independent from its complete visual
// identity, matching how the legacy YAML template represented this service.
defaultValue: { enabled: false, botName: 'Barcode Games', profileImageUrl: 'https://example.com/barcode-games.png' },
schema: strictObject({
enabled: boolean(),
botName: string({ minLength: 1, maxLength: 80 }),
profileImageUrl: string({ title: 'Profile image URL', maxLength: 2048 }),
}, { title: 'Barcode games', required: ['enabled', 'botName', 'profileImageUrl'] }),
enabled: boolean({ description: 'Enables shared barcode-game voting, participation, scoring, and game-state publication.' }),
botName: string({ description: 'Nickname used for barcode-game lifecycle messages posted into chat.', minLength: 1, maxLength: 80 }),
profileImageUrl: string({ title: 'Profile image URL', description: 'Optional image URL displayed beside barcode-game chat messages; leave blank for no custom image.', examples: ['https://example.com/barcode-games.png'], maxLength: 2048 }),
}, { title: 'Barcode games', description: 'Controls the multiplayer games driven by scans received from the barcode scanner service.', required: ['enabled', 'botName', 'profileImageUrl'] }),
};
@@ -7,5 +7,11 @@ module.exports = {
key: 'barcodeScanner',
feature: true,
defaultValue: { enabled: false },
schema: strictObject({ enabled: boolean() }, { title: 'Barcode scanner', required: ['enabled'] }),
schema: strictObject({
enabled: boolean({ description: 'Registers barcode scanning, barcode administration, and scan-triggered server behavior after restart.' }),
}, {
title: 'Barcode scanner',
description: 'Optional physical barcode scanning and barcode registry service.',
required: ['enabled'],
}),
};
@@ -7,5 +7,11 @@ module.exports = {
key: 'buttonBox',
feature: true,
defaultValue: { enabled: false },
schema: strictObject({ enabled: boolean() }, { title: 'Button box', required: ['enabled'] }),
schema: strictObject({
enabled: boolean({ description: 'Registers the physical button-box input route and enables its persistent button rewards and effects after restart.' }),
}, {
title: 'Button box',
description: 'Optional physical button-box input and reward system.',
required: ['enabled'],
}),
};
@@ -6,31 +6,56 @@ const { strictObject, string, boolean } = require('../../configuration/schemaHel
module.exports = {
key: 'discord',
feature: true,
// Channel and role IDs retain the fully populated legacy-template shape, but
// the credential remains empty and the bot cannot start until explicitly enabled.
defaultValue: {
enabled: false,
token: '',
guildId: '',
siteUrl: '',
channels: { general: '', announcements: '', adminAlerts: '', replay: '', humanAlerts: '' },
roles: { stalkerPing: '', announcementPing: '', adminPing: '', humanAlertPing: '' },
guildId: '123456789012345678',
siteUrl: 'https://rover.example.com',
channels: {
general: '123456789012345678',
announcements: '123456789012345678',
adminAlerts: '123456789012345678',
replay: '123456789012345678',
humanAlerts: '123456789012345678',
},
roles: {
stalkerPing: '123456789012345678',
announcementPing: '123456789012345678',
adminPing: '123456789012345678',
humanAlertPing: '123456789012345678',
},
},
schema: strictObject({
enabled: boolean(),
token: string({ title: 'Bot token', writeOnly: true, maxLength: 10000 }),
guildId: string({ title: 'Guild id', maxLength: 100 }),
siteUrl: string({ title: 'Public site URL', maxLength: 2048 }),
enabled: boolean({ description: 'Logs the Discord bot in and enables commands, chat bridges, replay delivery, and configured announcements after restart.' }),
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }),
guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }),
siteUrl: string({ title: 'Public site URL', description: 'Public base URL appended to announcement embeds and server-hosted replay links.', examples: ['https://rover.example.com'], maxLength: 2048 }),
channels: strictObject({
general: string({ maxLength: 100 }),
announcements: string({ maxLength: 100 }),
adminAlerts: string({ maxLength: 100 }),
replay: string({ maxLength: 100 }),
humanAlerts: string({ maxLength: 100 }),
}, { required: ['general', 'announcements', 'adminAlerts', 'replay', 'humanAlerts'] }),
general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }),
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }),
adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', examples: ['123456789012345678'], maxLength: 100 }),
replay: string({ description: 'Channel ID used to upload generated replay videos when Discord replay delivery is available.', examples: ['123456789012345678'], maxLength: 100 }),
humanAlerts: string({ description: 'Channel ID used for physical human-alert button notifications and captured images.', examples: ['123456789012345678'], maxLength: 100 }),
}, {
title: 'Channels',
description: 'Discord channel IDs that route each category of bot output.',
required: ['general', 'announcements', 'adminAlerts', 'replay', 'humanAlerts'],
}),
roles: strictObject({
stalkerPing: string({ maxLength: 100 }),
announcementPing: string({ maxLength: 100 }),
adminPing: string({ maxLength: 100 }),
humanAlertPing: string({ maxLength: 100 }),
}, { required: ['stalkerPing', 'announcementPing', 'adminPing', 'humanAlertPing'] }),
}, { title: 'Discord', required: ['enabled', 'token', 'guildId', 'siteUrl', 'channels', 'roles'] }),
stalkerPing: string({ description: 'Role ID mentioned by the button-box stalker-ping reward in the general channel.', examples: ['123456789012345678'], maxLength: 100 }),
announcementPing: string({ description: 'Role ID mentioned by configured user announcements.', examples: ['123456789012345678'], maxLength: 100 }),
adminPing: string({ description: 'Role ID mentioned for important administrative rover, battery, and help alerts.', examples: ['123456789012345678'], maxLength: 100 }),
humanAlertPing: string({ description: 'Role ID mentioned when the physical human-alert button is pressed.', examples: ['123456789012345678'], maxLength: 100 }),
}, {
title: 'Roles',
description: 'Discord role IDs mentioned for specific notification categories.',
required: ['stalkerPing', 'announcementPing', 'adminPing', 'humanAlertPing'],
}),
}, {
title: 'Discord',
description: 'Optional Discord bot credentials, public URL, and notification routing.',
required: ['enabled', 'token', 'guildId', 'siteUrl', 'channels', 'roles'],
}),
};
@@ -14,21 +14,43 @@ module.exports = {
privacy: { retainChatBodies: true },
},
schema: strictObject({
enabled: boolean(),
enabled: boolean({ description: 'Starts persistent fleet metric collection, reports, retention cleanup, and configured daily delivery after restart.' }),
retention: strictObject({
detailedDays: integer({ description: 'Zero retains indefinitely.', minimum: 0, maximum: 36500 }),
minuteSamplesDays: integer({ description: 'Zero retains indefinitely.', minimum: 0, maximum: 36500 }),
}, { required: ['detailedDays', 'minuteSamplesDays'] }),
detailedDays: integer({ description: 'Days to retain detailed events, command observations, sessions, and other non-minute fleet records. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
minuteSamplesDays: integer({ description: 'Days to retain per-minute rover metric aggregates. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
}, {
title: 'Retention',
description: 'Automatic cleanup windows for the two classes of fleet-report records.',
required: ['detailedDays', 'minuteSamplesDays'],
}),
battery: strictObject({
enabled: boolean(),
maximumIntegrationGapSeconds: number({ minimum: 0.1, maximum: 3600 }),
minimumCapacityTestDepthPercent: number({ minimum: 0, maximum: 100 }),
}, { required: ['enabled', 'maximumIntegrationGapSeconds', 'minimumCapacityTestDepthPercent'] }),
enabled: boolean({ description: 'Collects high-frequency battery sensor readings and derives charging, discharge, energy, and capacity metrics.' }),
maximumIntegrationGapSeconds: number({ description: 'Largest allowed gap in seconds between battery readings before energy integration treats the telemetry as discontinuous.', minimum: 0.1, maximum: 3600 }),
minimumCapacityTestDepthPercent: number({ description: 'Minimum observed full-to-low discharge depth required before a continuous session qualifies as a high-confidence capacity test. Runtime enforces at least 10 percent.', minimum: 0, maximum: 100 }),
}, {
title: 'Battery',
description: 'Battery telemetry collection and quality thresholds used by fleet reports.',
required: ['enabled', 'maximumIntegrationGapSeconds', 'minimumCapacityTestDepthPercent'],
}),
discord: strictObject({
enabled: boolean(),
sendAt: string({ pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }),
timezone: string({ minLength: 1, maxLength: 100 }),
}, { required: ['enabled', 'sendAt', 'timezone'] }),
privacy: strictObject({ retainChatBodies: boolean() }, { required: ['retainChatBodies'] }),
}, { title: 'Fleet reports', required: ['enabled', 'retention', 'battery', 'discord', 'privacy'] }),
enabled: boolean({ description: 'Sends one completed daily fleet report to the configured Discord administrative-alert channel.' }),
sendAt: string({ description: 'Local time of day to send the daily report, written as 24-hour HH:mm.', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }),
timezone: string({ description: 'IANA timezone used to interpret the delivery time and determine each completed report day.', minLength: 1, maxLength: 100 }),
}, {
title: 'Discord delivery',
description: 'Schedule for sending completed daily fleet summaries through the Discord bot.',
required: ['enabled', 'sendAt', 'timezone'],
}),
privacy: strictObject({
retainChatBodies: boolean({ description: 'Reserved privacy preference. The current collector does not read this setting and preserves complete event payloads, including chat content, regardless of its value.' }),
}, {
title: 'Privacy',
description: 'Privacy controls reserved for any future fleet-report collection of message content.',
required: ['retainChatBodies'],
}),
}, {
title: 'Fleet reports',
description: 'Persistent fleet operations reporting, retention, battery analysis, delivery, and privacy preferences.',
required: ['enabled', 'retention', 'battery', 'discord', 'privacy'],
}),
};
@@ -11,39 +11,82 @@ module.exports = {
// Retain the actual child definitions so generic configuration metadata can
// discover their feature switches without repeating nested paths centrally.
nestedDefinitions: [neato, lift],
// Example entities and triggers are real initial document values, as they
// were in the YAML template. Home Assistant stays inert until enabled and a
// real secret is deliberately installed by the operator.
defaultValue: {
enabled: false,
url: 'http://127.0.0.1:8123',
token: '',
[neato.key]: neato.defaultValue,
[lift.key]: lift.defaultValue,
entities: [],
buttons: [],
entities: [
{ id: 'light.lab_main', name: 'Lab Lights' },
{ id: 'switch.dock_power', name: 'Dock Power' },
],
buttons: [
{
entityId: 'sensor.basement_rover_buttons_action',
stateEquals: 'on',
cooldownMs: 15000,
action: 'humanAlert',
},
{
entityId: 'sensor.basement_rover_buttons_action',
stateEquals: 'double',
cooldownMs: 2000,
action: 'modeTurns',
},
{
entityId: 'sensor.basement_rover_buttons_action',
stateEquals: 'hold',
cooldownMs: 2000,
action: 'modeAdmin',
},
{
entityId: 'sensor.basement_rover_buttons_action',
stateEquals: 'toggle',
cooldownMs: 1000,
action: 'lightsLockToggle',
},
],
},
schema: strictObject({
enabled: boolean(),
url: string({ title: 'Server URL', format: 'uri', maxLength: 2048 }),
token: string({ title: 'Long-lived access token', writeOnly: true, maxLength: 20000 }),
enabled: boolean({ description: 'Connects to Home Assistant and enables configured room entities, physical-button triggers, Neato controls, and lift controls after restart.' }),
url: string({ title: 'Server URL', description: 'Base URL of the Home Assistant server used for its REST and WebSocket APIs.', format: 'uri', maxLength: 2048 }),
token: string({ title: 'Long-lived access token', description: 'Home Assistant long-lived access token used to authenticate every API request. The saved value is never returned to the browser.', examples: ['REPLACE_WITH_LONG_LIVED_TOKEN'], writeOnly: true, maxLength: 20000 }),
[neato.key]: neato.schema,
[lift.key]: lift.schema,
entities: {
type: 'array',
title: 'Room entities',
description: 'Home Assistant lights and switches exposed to the room-light controls and button-box actions.',
items: strictObject({
id: string({ title: 'Entity id', minLength: 1, maxLength: 255 }),
name: string({ minLength: 1, maxLength: 120 }),
type: string({ enum: ['light', 'switch'] }),
}, { required: ['id', 'name'] }),
id: string({ title: 'Entity id', description: 'Exact Home Assistant entity ID, such as light.rover_room or switch.floor_lamp.', examples: ['light.lab_main'], minLength: 1, maxLength: 255 }),
name: string({ description: 'Human-readable name shown for this entity in the rover UI.', examples: ['Lab Lights'], minLength: 1, maxLength: 120 }),
type: string({ description: 'Control behavior to expose: lights receive brightness-aware commands, while switches receive simple on and off commands.', enum: ['light', 'switch'] }),
}, {
description: 'One Home Assistant entity that the rover server can display and control.',
required: ['id', 'name'],
}),
},
buttons: {
type: 'array',
title: 'Physical button mappings',
description: 'Maps Home Assistant entity state changes to built-in rover-server actions.',
items: strictObject({
entityId: string({ title: 'Entity id', minLength: 1, maxLength: 255 }),
stateEquals: string({ minLength: 1, maxLength: 255 }),
cooldownMs: integer({ minimum: 0, maximum: 86400000 }),
action: string({ enum: ['humanAlert', 'modeTurns', 'modeAdmin', 'lightsLockToggle'] }),
}, { required: ['entityId', 'stateEquals', 'cooldownMs', 'action'] }),
entityId: string({ title: 'Entity id', description: 'Home Assistant entity whose state changes are watched as button presses.', examples: ['sensor.basement_rover_buttons_action'], minLength: 1, maxLength: 255 }),
stateEquals: string({ description: 'Exact Home Assistant state that must be reached before the action fires.', examples: ['on'], minLength: 1, maxLength: 255 }),
cooldownMs: integer({ description: 'Minimum milliseconds between accepted activations of this mapping.', examples: [15000], minimum: 0, maximum: 86400000 }),
action: string({ description: 'Built-in action to run: raise a human alert, switch to turns mode, switch to admin mode, or toggle the room-light lock.', enum: ['humanAlert', 'modeTurns', 'modeAdmin', 'lightsLockToggle'] }),
}, {
description: 'One watched Home Assistant state transition and the server action it triggers.',
required: ['entityId', 'stateEquals', 'cooldownMs', 'action'],
}),
},
}, { title: 'Home Assistant', required: ['enabled', 'url', 'token', 'neato', 'lift', 'entities', 'buttons'] }),
}, {
title: 'Home Assistant',
description: 'Connection, controllable entity catalog, and hardware-trigger mappings for the shared Home Assistant integration.',
required: ['enabled', 'url', 'token', 'neato', 'lift', 'entities', 'buttons'],
}),
};
@@ -6,23 +6,37 @@ const { strictObject, string, boolean, integer, stringArray } = require('../../c
module.exports = {
key: 'interInstance',
feature: true,
// Optional behavior remains disabled, but a new configuration now starts
// with the same complete, editable template that the former YAML supplied.
defaultValue: {
enabled: false,
directoryUrls: [],
directoryUrls: ['https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json'],
pollIntervalMs: 30000,
requestTimeoutMs: 5000,
profile: { publicUrl: '', name: 'MultiRover', description: '', color: '#38bdf8' },
profile: {
publicUrl: 'https://rover.example.com',
name: 'Example Rover Server',
description: 'A short public description of this rover server.',
color: '#38bdf8',
},
},
schema: strictObject({
enabled: boolean(),
directoryUrls: stringArray({ item: { format: 'uri' } }),
pollIntervalMs: integer({ minimum: 1000, maximum: 86400000 }),
requestTimeoutMs: integer({ minimum: 250, maximum: 120000 }),
enabled: boolean({ description: 'Publishes this server\'s public instance information and polls the configured directories for peer servers.' }),
directoryUrls: stringArray({
item: {
description: 'Absolute URL returning an array of peer MultiRover instance entries.',
examples: ['https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json'],
format: 'uri',
},
array: { description: 'Directory endpoints polled to discover other public MultiRover servers.' },
}),
pollIntervalMs: integer({ description: 'Milliseconds between peer-directory refreshes.', minimum: 1000, maximum: 86400000 }),
requestTimeoutMs: integer({ description: 'Maximum milliseconds allowed for each directory or peer information request before it is aborted.', minimum: 250, maximum: 120000 }),
profile: strictObject({
publicUrl: string({ maxLength: 2048 }),
name: string({ minLength: 1, maxLength: 120 }),
description: string({ maxLength: 500 }),
color: string({ pattern: '^#[0-9a-fA-F]{6}$' }),
}, { required: ['publicUrl', 'name', 'description', 'color'] }),
}, { title: 'Inter-instance directory', required: ['enabled', 'directoryUrls', 'pollIntervalMs', 'requestTimeoutMs', 'profile'] }),
publicUrl: string({ description: 'Public base URL peers and users use to reach this server; it also identifies and filters this instance from directory results.', examples: ['https://rover.example.com'], maxLength: 2048 }),
name: string({ description: 'Public instance name advertised to peer servers.', minLength: 1, maxLength: 120 }),
description: string({ description: 'Short public summary advertised with this instance.', examples: ['A short public description of this rover server.'], maxLength: 500 }),
color: string({ description: 'Six-digit hexadecimal accent color advertised for this instance.', pattern: '^#[0-9a-fA-F]{6}$' }),
}, { description: 'Public identity this server publishes through the inter-instance information endpoint.', required: ['publicUrl', 'name', 'description', 'color'] }),
}, { title: 'Inter-instance directory', description: 'Controls discovery and public information exchange between independent MultiRover servers.', required: ['enabled', 'directoryUrls', 'pollIntervalMs', 'requestTimeoutMs', 'profile'] }),
};
@@ -8,7 +8,11 @@ module.exports = {
feature: true,
defaultValue: { enabled: false, captureCooldownMs: 10000 },
schema: strictObject({
enabled: boolean(),
captureCooldownMs: integer({ minimum: 0, maximum: 3600000 }),
}, { title: 'Kinect', required: ['enabled', 'captureCooldownMs'] }),
enabled: boolean({ description: 'Starts the Kinect worker and exposes authorized frame capture after restart.' }),
captureCooldownMs: integer({ description: 'Minimum milliseconds between accepted Kinect frame-capture requests across all clients.', minimum: 0, maximum: 3600000 }),
}, {
title: 'Kinect',
description: 'Optional Kinect frame capture and its server-wide request cooldown.',
required: ['enabled', 'captureCooldownMs'],
}),
};
@@ -6,12 +6,24 @@ const { strictObject, string, boolean, integer } = require('../../configuration/
module.exports = {
key: 'lift',
feature: true,
defaultValue: { enabled: false, upSwitch: '', downSwitch: '', interlockMs: 2000, commandCooldownMs: 3000 },
// These inert example entity IDs preserve the complete former YAML shape;
// the explicit feature switch remains the only activation signal.
defaultValue: {
enabled: false,
upSwitch: 'switch.lift_up',
downSwitch: 'switch.lift_down',
interlockMs: 2000,
commandCooldownMs: 3000,
},
schema: strictObject({
enabled: boolean(),
upSwitch: string({ maxLength: 255 }),
downSwitch: string({ maxLength: 255 }),
interlockMs: integer({ minimum: 0, maximum: 600000 }),
commandCooldownMs: integer({ minimum: 0, maximum: 600000 }),
}, { title: 'Lift', required: ['enabled', 'upSwitch', 'downSwitch', 'interlockMs', 'commandCooldownMs'] }),
enabled: boolean({ description: 'Enables lift status and commands through the two configured Home Assistant switches after restart.' }),
upSwitch: string({ description: 'Home Assistant switch entity that powers upward lift movement.', examples: ['switch.lift_up'], maxLength: 255 }),
downSwitch: string({ description: 'Home Assistant switch entity that powers downward lift movement.', examples: ['switch.lift_down'], maxLength: 255 }),
interlockMs: integer({ description: 'Milliseconds to wait after turning off the opposing direction before energizing the requested direction. Runtime always enforces at least 250 ms.', minimum: 0, maximum: 600000 }),
commandCooldownMs: integer({ description: 'Minimum milliseconds between lift commands. Runtime never allows this to be shorter than the interlock delay.', minimum: 0, maximum: 600000 }),
}, {
title: 'Lift',
description: 'Bidirectional lift control using interlocked Home Assistant switch entities.',
required: ['enabled', 'upSwitch', 'downSwitch', 'interlockMs', 'commandCooldownMs'],
}),
};
@@ -7,9 +7,9 @@ module.exports = {
key: 'llmCommentary',
defaultValue: { enabled: false, model: 'qwen2.5:7b-instruct', ollamaServer: 'http://127.0.0.1:11434', frequency: 120000 },
schema: strictObject({
enabled: boolean(),
model: string({ minLength: 1, maxLength: 200 }),
ollamaServer: string({ title: 'Ollama server', format: 'uri', maxLength: 2048 }),
enabled: boolean({ description: 'Starts periodic AI commentary generation from current rover, user, and chat activity.' }),
model: string({ description: 'Ollama model name used to generate commentary.', minLength: 1, maxLength: 200 }),
ollamaServer: string({ title: 'Ollama server', description: 'Base URL of the Ollama API used for commentary generation.', format: 'uri', maxLength: 2048 }),
frequency: integer({ description: 'Commentary interval in milliseconds.', minimum: 1000, maximum: 86400000 }),
}, { title: 'LLM commentary', required: ['enabled', 'model', 'ollamaServer', 'frequency'] }),
}, { title: 'LLM commentary', description: 'Generates periodic AI-authored chat commentary from recent server activity through Ollama.', required: ['enabled', 'model', 'ollamaServer', 'frequency'] }),
};
@@ -5,9 +5,18 @@ const { strictObject, string, stringArray } = require('../../configuration/schem
module.exports = {
key: 'media',
defaultValue: { whepBaseUrl: 'http://127.0.0.1:8889/video', additionalHosts: [] },
defaultValue: {
// Signaling remains server-local because the internal `/video` proxy owns
// browser access; only ICE transport addresses come from the legacy sample.
whepBaseUrl: 'http://127.0.0.1:8889/video',
additionalHosts: ['rover.example.com', 'media-server.local'],
},
schema: strictObject({
whepBaseUrl: string({ title: 'WHEP base URL', format: 'uri', maxLength: 2048 }),
additionalHosts: stringArray({ title: 'Additional ICE hosts', item: { minLength: 1, maxLength: 255 }, array: { uniqueItems: true } }),
}, { title: 'Media', required: ['whepBaseUrl', 'additionalHosts'] }),
whepBaseUrl: string({ title: 'WHEP base URL', description: 'Base HTTP URL used to build browser WHEP playback and WHIP audio-publishing endpoints.', format: 'uri', maxLength: 2048 }),
additionalHosts: stringArray({
title: 'Additional ICE hosts',
item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 },
array: { description: 'Additional public or LAN hostnames and addresses browsers may use to reach MediaMTX WebRTC transport.', uniqueItems: true },
}),
}, { title: 'Media', description: 'Controls browser signaling addresses and WebRTC network candidates generated for the managed MediaMTX process.', required: ['whepBaseUrl', 'additionalHosts'] }),
};
@@ -6,9 +6,15 @@ const { strictObject, string, boolean } = require('../../configuration/schemaHel
module.exports = {
key: 'neato',
feature: true,
defaultValue: { enabled: false, device: '' },
// Keeping the example device in the saved template explains the required
// ESPHome naming shape while `enabled: false` prevents accidental control.
defaultValue: { enabled: false, device: 'neato_vacuum' },
schema: strictObject({
enabled: boolean(),
device: string({ description: 'ESPHome device name.', maxLength: 255 }),
}, { title: 'Neato', required: ['enabled', 'device'] }),
enabled: boolean({ description: 'Exposes Neato status and commands through the configured Home Assistant ESPHome device after restart.' }),
device: string({ description: 'ESPHome device name used to derive the Neato entity IDs in Home Assistant; punctuation is normalized to underscores.', examples: ['neato_vacuum'], maxLength: 255 }),
}, {
title: 'Neato',
description: 'Optional Neato robot controls backed by entities published from one ESPHome device through Home Assistant.',
required: ['enabled', 'device'],
}),
};
@@ -7,7 +7,11 @@ module.exports = {
key: 'commands',
defaultValue: { prefix: 'rs', timeStatusCommand: 'ts' },
schema: strictObject({
prefix: string({ minLength: 1, maxLength: 20 }),
timeStatusCommand: nullableString({ description: 'Leave empty to disable the bare shortcut.', maxLength: 20 }),
}, { title: 'Commands', required: ['prefix', 'timeStatusCommand'] }),
prefix: string({ description: 'Text placed before operator commands in web chat and Discord, such as rs help.', minLength: 1, maxLength: 20 }),
timeStatusCommand: nullableString({ description: 'Optional command accepted without the normal prefix for the current time and rover status. Leave empty to disable the shortcut.', maxLength: 20 }),
}, {
title: 'Commands',
description: 'Shared text syntax used by operator commands across web chat and Discord.',
required: ['prefix', 'timeStatusCommand'],
}),
};
@@ -5,6 +5,8 @@ const { strictObject, string, boolean, integer } = require('../../configuration/
module.exports = {
key: 'overseerControl',
// The integration is still opt-in; populated presentation values keep its
// initial document complete without causing the model loop to start.
defaultValue: {
enabled: false,
mode: 'autonomous',
@@ -15,20 +17,20 @@ module.exports = {
name: 'The Overseer',
model: 'qwen2.5:7b-instruct',
ollamaServer: 'http://127.0.0.1:11434',
profileImageUrl: '',
profileImageUrl: 'https://example.com/overseer.png',
gateIntervalMs: 2000,
},
schema: strictObject({
enabled: boolean(),
mode: string({ enum: ['autonomous', 'directAddress'] }),
observeOnly: boolean(),
postToolsOnlyMessages: boolean(),
tiebreakerEnable: boolean(),
runWhileNoPeopleOnline: boolean(),
name: string({ minLength: 1, maxLength: 80 }),
model: string({ minLength: 1, maxLength: 200 }),
ollamaServer: string({ title: 'Ollama server', format: 'uri', maxLength: 2048 }),
profileImageUrl: string({ title: 'Profile image URL', maxLength: 2048 }),
gateIntervalMs: integer({ minimum: 250, maximum: 3600000 }),
}, { title: 'Overseer Control', required: ['enabled', 'mode', 'observeOnly', 'postToolsOnlyMessages', 'tiebreakerEnable', 'runWhileNoPeopleOnline', 'name', 'model', 'ollamaServer', 'profileImageUrl', 'gateIntervalMs'] }),
enabled: boolean({ description: 'Enables the AI Overseer and its configured autonomous or direct-address execution path.' }),
mode: string({ description: 'autonomous runs repeatedly when the user vote permits it; directAddress runs only when chat begins with the configured Overseer name.', enum: ['autonomous', 'directAddress'] }),
observeOnly: boolean({ description: 'Lets the model evaluate state without executing tools or posting its generated chat response.' }),
postToolsOnlyMessages: boolean({ description: 'Posts a chat feed entry for executed tool calls even when the model did not also produce chat text.' }),
tiebreakerEnable: boolean({ description: 'Allows autonomous execution when eligible users are evenly split between enabling and disabling the Overseer.' }),
runWhileNoPeopleOnline: boolean({ description: 'Allows autonomous execution when no eligible users are online to vote.' }),
name: string({ description: 'Chat identity for Overseer messages and the phrase that triggers direct-address mode.', minLength: 1, maxLength: 80 }),
model: string({ description: 'Ollama model name used for Overseer decisions.', minLength: 1, maxLength: 200 }),
ollamaServer: string({ title: 'Ollama server', description: 'Base URL of the Ollama API used for Overseer decisions.', format: 'uri', maxLength: 2048 }),
profileImageUrl: string({ title: 'Profile image URL', description: 'Optional image URL displayed beside Overseer chat messages; leave blank for no custom image.', examples: ['https://example.com/overseer.png'], maxLength: 2048 }),
gateIntervalMs: integer({ description: 'Milliseconds waited after a completed autonomous decision before evaluating the next one.', minimum: 250, maximum: 3600000 }),
}, { title: 'Overseer Control', description: 'Controls the AI agent that observes server state, optionally executes approved tools, and can speak in chat.', required: ['enabled', 'mode', 'observeOnly', 'postToolsOnlyMessages', 'tiebreakerEnable', 'runWhileNoPeopleOnline', 'name', 'model', 'ollamaServer', 'profileImageUrl', 'gateIntervalMs'] }),
};
@@ -6,28 +6,34 @@ const { strictObject, string, boolean, integer } = require('../../configuration/
module.exports = {
key: 'ptzCamera',
feature: true,
// Non-secret commissioning values mirror the legacy template. The password
// remains empty and `enabled: false` prevents an accidental camera login.
defaultValue: {
enabled: false,
name: 'PTZ Camera',
color: '#38bdf8',
host: '',
host: '192.168.0.8',
onvifPort: 8000,
username: '',
username: 'admin',
password: '',
profileToken: '003',
turnDurationMs: 300000,
replayEnabled: false,
},
schema: strictObject({
enabled: boolean(),
name: string({ minLength: 1, maxLength: 120 }),
color: string({ pattern: '^#[0-9a-fA-F]{6}$' }),
host: string({ maxLength: 255 }),
onvifPort: integer({ title: 'ONVIF port', minimum: 1, maximum: 65535 }),
username: string({ maxLength: 255 }),
password: string({ writeOnly: true, maxLength: 10000 }),
profileToken: string({ maxLength: 255 }),
turnDurationMs: integer({ minimum: 1000, maximum: 86400000 }),
replayEnabled: boolean(),
}, { title: 'PTZ camera', required: ['enabled', 'name', 'color', 'host', 'onvifPort', 'username', 'password', 'profileToken', 'turnDurationMs', 'replayEnabled'] }),
enabled: boolean({ description: 'Connects to the configured ONVIF camera and exposes its controls after restart.' }),
name: string({ description: 'Human-readable camera name shown in the control interface.', minLength: 1, maxLength: 120 }),
color: string({ description: 'Six-digit hexadecimal accent color used to identify this camera in the UI.', pattern: '^#[0-9a-fA-F]{6}$' }),
host: string({ description: 'Hostname or IP address of the ONVIF camera.', examples: ['192.168.0.8'], maxLength: 255 }),
onvifPort: integer({ title: 'ONVIF port', description: 'TCP port used for ONVIF control requests.', minimum: 1, maximum: 65535 }),
username: string({ description: 'Camera account username used for ONVIF authentication.', examples: ['admin'], maxLength: 255 }),
password: string({ description: 'Camera account password used for ONVIF authentication. The saved value is never returned to the browser.', examples: ['REPLACE_WITH_CAMERA_PASSWORD'], writeOnly: true, maxLength: 10000 }),
profileToken: string({ description: 'ONVIF media profile token used for stream discovery, presets, status, and movement commands.', maxLength: 255 }),
turnDurationMs: integer({ description: 'Milliseconds assigned to each queued user turn controlling the PTZ camera.', minimum: 1000, maximum: 86400000 }),
replayEnabled: boolean({ description: 'Allows this camera to appear as an available replay source.' }),
}, {
title: 'PTZ camera',
description: 'Optional ONVIF pan-tilt-zoom camera connection, presentation, turn timing, and replay availability.',
required: ['enabled', 'name', 'color', 'host', 'onvifPort', 'username', 'password', 'profileToken', 'turnDurationMs', 'replayEnabled'],
}),
};
@@ -6,18 +6,49 @@ const { strictObject, string, boolean } = require('../../configuration/schemaHel
module.exports = {
key: 'roomCameras',
feature: true,
defaultValue: { enabled: false, cameras: [] },
// The example catalog documents the complete repeated-item shape as actual
// initial configuration while the feature switch prevents network requests.
defaultValue: {
enabled: false,
cameras: [
{
id: 'lobby',
name: 'Lobby Camera',
description: 'Wide shot of the staging area.',
url: 'http://192.168.0.50/snapshot.jpg',
streamUrl: 'http://192.168.0.50/stream.mjpg',
},
{
id: 'workshop',
name: 'Workshop Bench',
description: 'Shows the workbench and charging docks.',
url: 'http://192.168.0.51/snapshot.jpg',
streamUrl: 'http://192.168.0.51/stream.mjpg',
},
],
},
schema: strictObject({
enabled: boolean(),
enabled: boolean({ description: 'Publishes the configured room-camera catalog and enables camera snapshots and streams after restart.' }),
cameras: {
type: 'array',
description: 'Room cameras available to the web UI and replay system.',
items: strictObject({
id: string({ minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
name: string({ minLength: 1, maxLength: 120 }),
description: string({ maxLength: 500 }),
url: string({ title: 'Snapshot URL', format: 'uri', maxLength: 2048 }),
streamUrl: string({ title: 'Stream URL', maxLength: 2048 }),
}, { required: ['id', 'name', 'description', 'url', 'streamUrl'] }),
id: string({ description: 'Stable camera identifier used in socket requests, selections, and replay source names.', examples: ['lobby'], minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
name: string({ description: 'Human-readable camera name shown in the UI.', examples: ['Lobby Camera'], minLength: 1, maxLength: 120 }),
description: string({ description: 'Optional explanation of the camera location or view shown in the UI.', examples: ['Wide shot of the staging area.'], maxLength: 500 }),
url: string({ title: 'Snapshot URL', description: 'Optional HTTP URL fetched when the server needs a still image from this camera.', examples: ['http://192.168.0.50/snapshot.jpg'], format: 'uri', maxLength: 2048 }),
streamUrl: string({ title: 'Stream URL', description: 'Optional live stream URL consumed by the server snapshot engine and replay capture path.', examples: ['http://192.168.0.50/stream.mjpg'], maxLength: 2048 }),
}, {
// The runtime has always accepted snapshot-only and stream-only camera
// entries, and treats descriptions as presentation metadata. Requiring
// all three optional values made working YAML impossible to import.
description: 'One named room camera with an optional description and any snapshot or live-stream sources it provides.',
required: ['id', 'name'],
}),
},
}, { title: 'Room cameras', required: ['enabled', 'cameras'] }),
}, {
title: 'Room cameras',
description: 'Optional catalog of fixed cameras used for room views, server-produced snapshots, and replay sources.',
required: ['enabled', 'cameras'],
}),
};
@@ -12,30 +12,53 @@ const timezone = {
const socials = {
key: 'socials',
feature: true,
defaultValue: { enabled: false, links: [] },
// A complete starter list matches the former YAML template without exposing
// it to users until the service-owned enabled switch is deliberately set.
defaultValue: {
enabled: false,
links: [
{
id: 'discord',
label: 'Discord',
url: 'https://discord.gg/your-invite',
icon: 'FaDiscord',
color: '#5865F2',
},
{
id: 'kofi',
label: 'Ko-fi',
url: 'https://ko-fi.com/your-handle',
icon: 'FaCoffee',
color: '#29ABE0',
},
],
},
schema: strictObject({
enabled: boolean({ title: 'Enabled' }),
enabled: boolean({ title: 'Enabled', description: 'Shows the configured social-link buttons in driver and inter-instance views.' }),
links: {
type: 'array',
title: 'Links',
description: 'Ordered social or community links presented to users when this feature is enabled.',
items: strictObject({
id: string({ minLength: 1, maxLength: 60, pattern: '^[a-zA-Z0-9_-]+$' }),
label: string({ minLength: 1, maxLength: 80 }),
url: string({ format: 'uri', maxLength: 2048 }),
icon: string({ maxLength: 80 }),
color: string({ pattern: '^#[0-9a-fA-F]{6}$' }),
}, { required: ['id', 'label', 'url', 'icon', 'color'] }),
id: string({ description: 'Stable identifier used by the UI to distinguish this link from the others.', examples: ['discord'], minLength: 1, maxLength: 60, pattern: '^[a-zA-Z0-9_-]+$' }),
label: string({ description: 'User-facing text displayed on the link button.', examples: ['Discord'], minLength: 1, maxLength: 80 }),
url: string({ description: 'Absolute destination opened when a user selects this link.', examples: ['https://discord.gg/your-invite'], format: 'uri', maxLength: 2048 }),
icon: string({ description: 'Icon name interpreted by the social-button UI; leave blank to use its fallback presentation.', examples: ['FaDiscord'], maxLength: 80 }),
color: string({ description: 'Six-digit hexadecimal accent color used for this link button.', examples: ['#5865F2'], pattern: '^#[0-9a-fA-F]{6}$' }),
}, { description: 'One social-link button shown to users.', required: ['id', 'label', 'url', 'icon', 'color'] }),
},
}, { title: 'Social links', required: ['enabled', 'links'] }),
}, { title: 'Social links', description: 'Controls the optional social and community buttons published to local users and peer instances.', required: ['enabled', 'links'] }),
};
const driverAd = {
key: 'driverAd',
defaultValue: { title: '', html: '' },
// The title is harmless presentation metadata, while the HTML stays empty so
// a new installation never displays active sample content to drivers.
defaultValue: { title: 'Advertisement', html: '' },
schema: strictObject({
title: string({ maxLength: 120 }),
html: string({ title: 'HTML', description: 'Trusted operator HTML shown to drivers.', maxLength: 100000 }),
}, { title: 'Driver content', required: ['title', 'html'] }),
title: string({ description: 'Heading displayed above the operator-provided content on the driver page; leave blank to use the card fallback.', examples: ['Advertisement'], maxLength: 120 }),
html: string({ title: 'HTML', description: 'Trusted operator HTML shown to drivers.', examples: ['<a href="https://example.com" target="_blank" rel="noopener noreferrer"><img src="https://example.com/ad.png" alt="Advertisement"></a>'], maxLength: 100000 }),
}, { title: 'Driver content', description: 'Operator-managed informational or promotional content displayed in the driver application.', required: ['title', 'html'] }),
};
function getConfiguredSocials(config) {
+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,11 +1,39 @@
// 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 { createContext, useContext, useMemo } from 'react';
import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import CardFrame from '../../components/CardFrame/index.jsx';
/*
Structural colors repeat only after six levels, which is deeper than the
current configuration document but remains safe for future service-owned
schemas. Depth is carried through React context because RJSF's array-item
template does not receive a field path; context keeps objects, arrays, and
array items in one generic hierarchy without attaching UI metadata to the
server's schema.
*/
const CONFIGURATION_LAYER_COLORS = [
// Alternating warm and cool hues keep adjacent structural levels distinct
// even after CardFrame applies its intentionally subtle translucent tint.
'#0ea5e9', // blue
'#f97316', // orange
'#22c55e', // green
'#a855f7', // purple
'#ef4444', // red
'#eab308', // yellow
];
const ConfigurationLayerDepthContext = createContext(0);
function useConfigurationLayer() {
const depth = useContext(ConfigurationLayerDepthContext);
return {
color: CONFIGURATION_LAYER_COLORS[depth % CONFIGURATION_LAYER_COLORS.length],
depth,
};
}
function buildUiSchema(schema, path = '') {
/*
`writeOnly` is standard JSON Schema metadata and is the sole reason a field
@@ -13,21 +41,50 @@ function buildUiSchema(schema, path = '') {
server secret-operation protocol; it contains no service or field names.
*/
if (!schema || typeof schema !== 'object') return {};
const firstExample = Array.isArray(schema.examples) ? schema.examples[0] : undefined;
const placeholder = firstExample === undefined ? {} : { 'ui:placeholder': String(firstExample) };
if (schema.writeOnly === true) {
return {
'ui:widget': 'SecretWidget',
'ui:options': { secretPath: path },
...placeholder,
};
}
if (schema.type !== 'object' || !schema.properties) return {};
if (schema.type === 'boolean') {
/*
RJSF's checkbox is unusual: unless it is explicitly selected, it renders
the schema description and field name inside the widget as well as
passing them to FieldTemplate. This generic option makes the surrounding
YAML-like row the sole owner of that text, eliminating duplicates without
maintaining a list of boolean setting names.
*/
return {
'ui:widget': 'checkbox',
'ui:options': { label: false },
};
}
if (schema.type === 'array' && schema.items) {
/*
Array item schemas need the same generic metadata walk as ordinary object
properties. Otherwise fields created by Add item would lose their
schema-owned placeholders even though the backend described them.
*/
return {
items: buildUiSchema(schema.items, path ? `${path}[]` : '[]'),
};
}
if (schema.type !== 'object' || !schema.properties) return placeholder;
return Object.fromEntries(Object.entries(schema.properties).map(([key, childSchema]) => [
key,
buildUiSchema(childSchema, path ? `${path}.${key}` : key),
]));
}
function SecretWidget({ id, disabled, readonly, options, registry }) {
function SecretWidget({ id, disabled, readonly, options, placeholder, registry }) {
const secretPath = options.secretPath;
const context = registry.formContext || {};
const operation = context.secretOperations?.[secretPath];
@@ -41,7 +98,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">
@@ -64,7 +121,7 @@ function SecretWidget({ id, disabled, readonly, options, registry }) {
type="password"
autoComplete="new-password"
value={operation.value}
placeholder="Enter replacement value"
placeholder={placeholder || 'Enter replacement value'}
disabled={unavailable}
onChange={(event) => setOperation({ action: 'replace', value: event.target.value })}
/>
@@ -77,7 +134,6 @@ function ConfigurationFieldTemplate({
children,
classNames,
description,
displayLabel,
errors,
help,
hidden,
@@ -98,74 +154,95 @@ 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}
</div>
<div className="configuration-value">
{/* Descriptions belong with the editable value rather than inside the
narrow key column. This keeps long operational guidance readable
without weakening the YAML-like key/value alignment. */}
{description ? <div className="configuration-value-description">{description}</div> : null}
{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);
const topLevel = fieldPathId.path.length === 1;
const layer = useConfigurationLayer();
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.
// The root description remains outside that stack so its introductory
// text does not accidentally become another configuration section.
return (
<div>
{description ? <div className="configuration-root-description">{description}</div> : null}
{/* The invisible schema root establishes depth zero. Every visible
structural CardFrame below it advances the context by one level. */}
<ConfigurationLayerDepthContext.Provider value={0}>
<div className="configuration-tree">{propertyLines}</div>
</ConfigurationLayerDepthContext.Provider>
</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) {
// item CardFrame. Rendering only their description and ordered properties
// prevents a redundant anonymous card inside that visible item boundary.
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>
<div>
{description ? <div className="configuration-item-description">{description}</div> : null}
{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>
<CardFrame
title={title}
color={layer.color}
clipOverflow={false}
className={`configuration-card${topLevel ? ' configuration-top-level-card' : ''}`}
bodyClassName="configuration-card-body"
>
{description ? <div className="configuration-branch-description">{description}</div> : null}
<ConfigurationLayerDepthContext.Provider value={layer.depth + 1}>
<div className="configuration-children">{propertyLines}</div>
</ConfigurationLayerDepthContext.Provider>
</CardFrame>
);
}
function ConfigurationArrayItemTemplate({ buttonsProps, children, hasToolbar, index }) {
const unavailable = buttonsProps.disabled || buttonsProps.readonly;
const layer = useConfigurationLayer();
return (
<article className="surface-muted space-y-0.5 border border-neutral-500/60 p-0.5">
<CardFrame
title={`Item ${index + 1}`}
color={layer.color}
clipOverflow={false}
className="configuration-card configuration-array-item"
bodyClassName="configuration-card-body"
>
{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>
// Actions remain at the beginning of the card body instead of using
// CardFrame's right-aligned action slot. Even on a wide editor, item
// controls therefore stay next to the content they affect.
<div className="configuration-item-actions">
{(buttonsProps.hasMoveUp || buttonsProps.hasMoveDown) ? (
<button type="button" className="button-dark text-xs" disabled={unavailable || !buttonsProps.hasMoveUp} onClick={buttonsProps.onMoveUpItem}>Move up</button>
) : null}
@@ -178,26 +255,42 @@ function ConfigurationArrayItemTemplate({ buttonsProps, children, hasToolbar, in
{buttonsProps.hasRemove ? (
<button type="button" className="button-danger text-xs" disabled={unavailable} onClick={buttonsProps.onRemoveItem}>Remove</button>
) : null}
</header>
</div>
) : null}
{children}
</article>
<ConfigurationLayerDepthContext.Provider value={layer.depth + 1}>
<div className="configuration-children">{children}</div>
</ConfigurationLayerDepthContext.Provider>
</CardFrame>
);
}
function ConfigurationArrayTemplate({ canAdd, disabled, items, onAddClick, readonly, schema, title }) {
function ConfigurationArrayTemplate({ canAdd, disabled, fieldPathId, items, onAddClick, readonly, schema, title }) {
const topLevel = fieldPathId.path.length === 1;
const layer = useConfigurationLayer();
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>}
<CardFrame
title={title}
meta={`${items.length} ${items.length === 1 ? 'item' : 'items'}`}
color={layer.color}
clipOverflow={false}
className={`configuration-card configuration-array${topLevel ? ' configuration-top-level-card' : ''}`}
bodyClassName="configuration-card-body"
>
{schema.description ? <div className="configuration-branch-description">{schema.description}</div> : null}
{canAdd ? (
<button type="button" className="button-dark text-xs" disabled={disabled || readonly} onClick={onAddClick}>Add item</button>
// Adding belongs to the collection as a whole, but stays left-aligned
// with that collection's contents rather than at the viewport edge.
<div className="configuration-array-actions">
<button type="button" className="button-dark text-xs" disabled={disabled || readonly} onClick={onAddClick}>Add item</button>
</div>
) : null}
</section>
<ConfigurationLayerDepthContext.Provider value={layer.depth + 1}>
<div className="configuration-children">
{items.length ? items : <p className="text-xs text-slate-500">No items configured.</p>}
</div>
</ConfigurationLayerDepthContext.Provider>
</CardFrame>
);
}
+111
View File
@@ -0,0 +1,111 @@
/* 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 {
/* The large gap is intentionally limited to root siblings. It makes each
top-level configuration section read as a separate YAML block while the
fields and cards inside each block remain compact. */
@apply mt-1 space-y-6;
}
.configuration-card {
@apply min-w-0;
}
.configuration-card .configuration-card {
/* Indent the next structural boundary itself, not every row owned by the
parent. Since nested CardFrames repeat this rule, the offset accumulates
naturally at each level just like indentation in a YAML document. */
margin-left: 1rem;
width: calc(100% - 1rem);
}
.configuration-card-body {
/* The shared card supplies the border, header, and background. Its body needs
only compact padding and vertical rhythm for descriptions, actions, rows,
and any nested CardFrames. */
@apply space-y-0.5 p-0.5;
}
.configuration-children {
/* Scalar rows belong directly to their current CardFrame and therefore do
not receive blanket indentation. Nested CardFrames handle their own offset
in the rule above, keeping keys aligned with their owning section. */
@apply space-y-0.5;
}
.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-root-description,
.configuration-branch-description,
.configuration-item-description,
.configuration-value-description {
/* Configuration guidance must remain comfortably readable; these are often
full operational explanations rather than disposable form hints. */
@apply block text-sm leading-snug text-slate-300;
margin-top: 0.125rem;
}
.configuration-root-description {
@apply mb-1;
}
.configuration-branch-description,
.configuration-item-description {
/* Structural descriptions sit inside the corresponding CardFrame body, so
no custom border, header background, or indentation treatment is needed. */
@apply max-w-[56rem];
}
.configuration-value-description {
/* A little separation from the widget keeps the help readable without
turning each setting back into a large card. */
@apply mb-0.5 max-w-[40rem];
}
.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-item-actions,
.configuration-array-actions {
/* Actions use ordinary flow intentionally. CardFrame's action slot is useful
elsewhere, but would separate these controls from wide configuration data. */
@apply flex flex-wrap gap-0.5;
}
+10 -10
View File
@@ -33,6 +33,7 @@ export default function CardFrame({
title = '',
meta = null,
actions = null,
color = null,
hideHeader = false,
className = '',
headerClassName = '',
@@ -50,27 +51,26 @@ export default function CardFrame({
const rover = roster.find((entry) => String(entry?.id) === roverId);
return rover?.color || null;
});
const accentRgb = hexToRgb(ownRoverColor);
// swap these to toggle rover card border colors stuff
// Green mode is global server chrome, so it wins over the assigned rover's
// personal accent while active. Keeping this override in CardFrame makes all
// present and future cards participate without sprinkling mode checks around.
// Callers can supply a structural accent, while ordinary cards continue to
// inherit the assigned rover's color without needing to know session state.
const accentRgb = hexToRgb(color || ownRoverColor);
const cardStyle = greenMode
? { borderColor: '#008a35' }
: accentRgb
? { borderColor: rgba(accentRgb, 0.3) }
: undefined;
// const cardStyle = undefined;
const headerStyle = greenMode
? { borderColor: '#008a35' }
: accentRgb
? {
// backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
// The header divider uses the same accent as the outside border. This
// makes the color describe the complete CardFrame rather than looking
// like an unrelated tint applied only behind its title.
borderColor: rgba(accentRgb, 0.3),
backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.2)} 100%)`,
// backgroundImage: `background-color: ${rgba(accentRgb, 0.2)}`
}
: undefined;
-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;
}
}