descriptionslop

This commit is contained in:
legop3
2026-09-14 12:34:22 -04:00
parent 4635e1b40c
commit 3c385126ae
37 changed files with 339 additions and 174 deletions
+3 -1
View File
@@ -443,6 +443,8 @@ Implemented on 2026-09-14:
- 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 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.
- Increased only the top-level section spacing while keeping nested configuration lines compact, moved option descriptions into the wider value column, and made root and array-item descriptions visible instead of discarding them.
- 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 +452,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 104 server tests passed, including complete schema-description 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, 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.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.configuration-tree{margin-top:.25rem}.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.configuration-branch{min-width:0px}.configuration-branch-heading,.configuration-item-heading{display:flex;flex-wrap:wrap;align-items:center;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity));padding:.125rem}.configuration-branch-heading h3,.configuration-item-title{font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-children{margin-left:.25rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-children{border-left-width:1px;border-color:#0369a1b3;padding-top:.125rem;padding-bottom:.125rem;padding-left:.25rem}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-root-description,.configuration-branch-description,.configuration-item-description,.configuration-value-description{display:block;font-size:.7rem;line-height:1.25;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity));margin-top:.125rem}.configuration-root-description{margin-bottom:.25rem}.configuration-branch-description,.configuration-item-description{margin-left:.125rem;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-array-item+.configuration-array-item{margin-top:.125rem}
@@ -1 +0,0 @@
.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-branch{min-width:0px}.configuration-branch-heading,.configuration-item-heading{display:flex;flex-wrap:wrap;align-items:center;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity));padding:.125rem}.configuration-branch-heading h3,.configuration-item-title{font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-branch-heading>div,.configuration-branch-description{font-size:.7rem;--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-children{margin-left:.25rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-children{border-left-width:1px;border-color:#0369a1b3;padding-top:.125rem;padding-bottom:.125rem;padding-left:.25rem}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-key .field-description,.configuration-key .help-block,.configuration-branch-description{display:block;font-size:.7rem;line-height:1.25;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity));margin-top:.125rem}.configuration-value input:not([type=checkbox]),.configuration-value select,.configuration-value textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity));padding:.125rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::-moz-placeholder,.configuration-value select::-moz-placeholder,.configuration-value textarea::-moz-placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::placeholder,.configuration-value select::placeholder,.configuration-value textarea::placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox]):focus,.configuration-value select:focus,.configuration-value textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(14 165 233 / var(--tw-ring-opacity))}.configuration-value input[type=checkbox]{height:1rem;width:1rem;vertical-align:middle;accent-color:#0ea5e9}.configuration-value .checkbox label{display:flex;min-height:1.75rem;align-items:center;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-value .error-detail{margin-top:.125rem;font-size:.75rem;line-height:1rem;--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity))}.configuration-value .help-block{margin-top:.125rem;display:block;font-size:.7rem;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity))}.configuration-secret{min-width:0px}.configuration-array-item+.configuration-array-item{margin-top:.125rem}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-DNzOwgk-.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-CgxV6A-m.js.map
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-_sqiQMZb.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-CkP7exMm.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
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-DNzOwgk-.js"></script>
<script type="module" crossorigin src="/assets/index-_sqiQMZb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1O6avznD.css">
</head>
<body>
@@ -20,6 +20,29 @@ 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;
}
test.after(() => {
temporaryRoots.forEach((root) => fs.rmSync(root, { recursive: true, force: true }));
});
@@ -46,6 +69,15 @@ 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('service definitions generate public feature paths without a separate registry', () => {
/*
This order follows the one configuration document, including nested Neato
+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'],
}),
};
@@ -8,8 +8,8 @@ module.exports = {
feature: true,
defaultValue: { enabled: false, botName: 'Barcode Games', profileImageUrl: '' },
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.', 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'],
}),
};
@@ -15,22 +15,34 @@ module.exports = {
roles: { stalkerPing: '', announcementPing: '', adminPing: '', humanAlertPing: '' },
},
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.', 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.', maxLength: 100 }),
siteUrl: string({ title: 'Public site URL', description: 'Public base URL appended to announcement embeds and server-hosted replay links.', 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.', maxLength: 100 }),
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', maxLength: 100 }),
adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', maxLength: 100 }),
replay: string({ description: 'Channel ID used to upload generated replay videos when Discord replay delivery is available.', maxLength: 100 }),
humanAlerts: string({ description: 'Channel ID used for physical human-alert button notifications and captured images.', 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.', maxLength: 100 }),
announcementPing: string({ description: 'Role ID mentioned by configured user announcements.', maxLength: 100 }),
adminPing: string({ description: 'Role ID mentioned for important administrative rover, battery, and help alerts.', maxLength: 100 }),
humanAlertPing: string({ description: 'Role ID mentioned when the physical human-alert button is pressed.', 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'],
}),
};
@@ -21,29 +21,41 @@ module.exports = {
buttons: [],
},
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.', 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.', minLength: 1, maxLength: 255 }),
name: string({ description: 'Human-readable name shown for this entity in the rover UI.', 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.', minLength: 1, maxLength: 255 }),
stateEquals: string({ description: 'Exact Home Assistant state that must be reached before the action fires.', minLength: 1, maxLength: 255 }),
cooldownMs: integer({ description: 'Minimum milliseconds between accepted activations of this mapping.', 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'],
}),
};
@@ -14,15 +14,18 @@ module.exports = {
profile: { publicUrl: '', name: 'MultiRover', description: '', 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.', 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.', 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.', 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'],
}),
};
@@ -8,10 +8,14 @@ module.exports = {
feature: true,
defaultValue: { enabled: false, upSwitch: '', downSwitch: '', 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.', maxLength: 255 }),
downSwitch: string({ description: 'Home Assistant switch entity that powers downward lift movement.', 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'] }),
};
@@ -7,7 +7,11 @@ module.exports = {
key: 'media',
defaultValue: { whepBaseUrl: 'http://127.0.0.1:8889/video', additionalHosts: [] },
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.', 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'] }),
};
@@ -8,7 +8,11 @@ module.exports = {
feature: true,
defaultValue: { enabled: false, device: '' },
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.', 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'],
}),
};
@@ -19,16 +19,16 @@ module.exports = {
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.', 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'] }),
};
@@ -19,15 +19,19 @@ module.exports = {
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.', 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.', maxLength: 255 }),
password: string({ description: 'Camera account password used for ONVIF authentication. The saved value is never returned to the browser.', 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'],
}),
};
@@ -8,16 +8,24 @@ module.exports = {
feature: true,
defaultValue: { enabled: false, cameras: [] },
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.', minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
name: string({ description: 'Human-readable camera name shown in the UI.', minLength: 1, maxLength: 120 }),
description: string({ description: 'Short explanation of the camera location or view shown in the UI.', maxLength: 500 }),
url: string({ title: 'Snapshot URL', description: 'HTTP URL fetched when the server needs a still image from this camera.', format: 'uri', maxLength: 2048 }),
streamUrl: string({ title: 'Stream URL', description: 'Live stream URL consumed by the server snapshot engine and replay capture path.', maxLength: 2048 }),
}, {
description: 'One named room camera with its still-image and live-stream sources.',
required: ['id', 'name', 'description', 'url', 'streamUrl'],
}),
},
}, { 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'],
}),
};
@@ -14,28 +14,29 @@ const socials = {
feature: true,
defaultValue: { enabled: false, links: [] },
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.', minLength: 1, maxLength: 60, pattern: '^[a-zA-Z0-9_-]+$' }),
label: string({ description: 'User-facing text displayed on the link button.', minLength: 1, maxLength: 80 }),
url: string({ description: 'Absolute destination opened when a user selects this link.', format: 'uri', maxLength: 2048 }),
icon: string({ description: 'Icon name interpreted by the social-button UI; leave blank to use its fallback presentation.', maxLength: 80 }),
color: string({ description: 'Six-digit hexadecimal accent color used for this link button.', 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: '' },
schema: strictObject({
title: string({ maxLength: 120 }),
title: string({ description: 'Heading displayed above the operator-provided content on the driver page; leave blank to use the card fallback.', maxLength: 120 }),
html: string({ title: 'HTML', description: 'Trusted operator HTML shown to drivers.', maxLength: 100000 }),
}, { title: 'Driver content', required: ['title', 'html'] }),
}, { title: 'Driver content', description: 'Operator-managed informational or promotional content displayed in the driver application.', required: ['title', 'html'] }),
};
function getConfiguredSocials(config) {
@@ -103,9 +103,12 @@ function ConfigurationFieldTemplate({
<label htmlFor={id} className="text-xs font-semibold text-slate-100">
{label}{required ? <span className="ml-0.25 text-sky-300">*</span> : null}
</label>
{description ? <div>{description}</div> : null}
</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}
@@ -121,22 +124,34 @@ function ConfigurationObjectTemplate({ description, fieldPathId, properties, tit
if (fieldPathId.path.length === 0) {
// The outer configuration card already names the root document. Rendering
// its properties directly makes their schema order read like YAML lines.
return <div className="configuration-tree">{propertyLines}</div>;
// 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}
<div className="configuration-tree">{propertyLines}</div>
</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 ordered property lines prevents redundant
// nested boxes such as "Item 1" followed by another anonymous object box.
return <div>{propertyLines}</div>;
// item template. Rendering only their description and ordered property
// lines prevents redundant boxes while preserving the item's own guidance.
return (
<div>
{description ? <div className="configuration-item-description">{description}</div> : null}
{propertyLines}
</div>
);
}
return (
<section className="configuration-branch">
<header className="configuration-branch-heading">
<h3>{title}</h3>
{description ? <div>{description}</div> : null}
</header>
{description ? <div className="configuration-branch-description">{description}</div> : null}
<div className="configuration-children">{propertyLines}</div>
</section>
);
+24 -10
View File
@@ -5,8 +5,9 @@
.configuration-tree {
/* A single ordered stack is the visual equivalent of YAML's top-to-bottom
document order. Separation comes from hierarchy, not unrelated tiles. */
@apply space-y-0.5;
document order. A deliberately large root gap makes the beginning and end
of every top-level section unmistakable, while nested lines stay compact. */
@apply mt-1 space-y-4;
}
.configuration-branch {
@@ -25,11 +26,6 @@
@apply text-sm font-semibold text-slate-100;
}
.configuration-branch-heading > div,
.configuration-branch-description {
@apply text-[0.7rem] text-slate-400;
}
.configuration-children {
/* Every container contributes exactly one indentation and one guide. Deep
descendants therefore explain their ancestry without calculating depth or
@@ -49,13 +45,31 @@
@apply min-w-0;
}
.configuration-key .field-description,
.configuration-key .help-block,
.configuration-branch-description {
.configuration-root-description,
.configuration-branch-description,
.configuration-item-description,
.configuration-value-description {
@apply block text-[0.7rem] leading-tight text-slate-500;
margin-top: 0.125rem;
}
.configuration-root-description {
@apply mb-1;
}
.configuration-branch-description,
.configuration-item-description {
/* Container help follows its heading at the same indentation; child keys
begin below it, preserving the same parent/child rhythm as YAML comments. */
@apply ml-0.5 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 {