mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
legacy importer in admin page aswell as setup.
This commit is contained in:
@@ -36,7 +36,7 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
|
||||
- All operator-controlled server configuration is stored in a validated database and managed through the web UI.
|
||||
- All mutable runtime state, generated files, caches, snapshots, recordings, and databases live under one server data directory.
|
||||
- A complete backup can capture that one data directory consistently, and a restore can safely replace it.
|
||||
- `/setup` may initialize the database from a YAML file explicitly selected by the operator; no automatic host migration exists.
|
||||
- `/setup` may initialize the database from a YAML file explicitly selected by the operator, and the protected Configuration page may explicitly replace configuration from one later; no automatic host migration or persistent YAML source exists.
|
||||
- A dedicated `/admin` application contains all server administration.
|
||||
- The public `/video` route is proxied to MediaMTX by the Node server, eliminating the special external MediaMTX proxy rule.
|
||||
- The completed server is packaged as a replaceable container whose only persistent mount is the data directory.
|
||||
@@ -204,25 +204,35 @@ After migration is complete:
|
||||
- Remove `config.yaml` and `config.example.yaml` from the repository and installation process.
|
||||
- Remove `js-yaml` if MediaMTX generation is changed to avoid it or if it is otherwise no longer needed. Generated MediaMTX YAML is an internal artifact, not operator configuration, so retaining `js-yaml` solely for that generator is acceptable.
|
||||
|
||||
## 3. Add optional configuration-file upload to setup
|
||||
## 3. Add explicit configuration-file upload to setup and administration
|
||||
|
||||
Container deployment starts with a new data directory and never discovers an old installation automatically. As a convenience, the first-run setup page may initialize the empty database from a YAML configuration file deliberately selected by the operator. This is not a startup loader, installer migration, command-line workflow, or permanent second source of truth.
|
||||
Container deployment starts with a new data directory and never discovers an old installation automatically. As a convenience, the first-run setup page may initialize the empty database from a YAML configuration file deliberately selected by the operator. The protected Configuration page may later replace only the configuration from another explicitly selected legacy file. Neither path is a startup loader, installer migration, command-line workflow, or permanent second source of truth.
|
||||
|
||||
The setup upload must:
|
||||
Every upload must:
|
||||
|
||||
- Accept only an explicitly selected YAML file from `/setup`.
|
||||
- Require the one-time setup code before processing it.
|
||||
- Accept only an explicitly selected YAML file from `/setup` or the protected Configuration page.
|
||||
- Parse the complete document.
|
||||
- Map every recognized field into the new configuration schema.
|
||||
- Preserve existing bcrypt administrator password hashes.
|
||||
- Preserve lockdown roles and Discord IDs.
|
||||
- Preserve secrets without printing them.
|
||||
- Apply current defaults for absent fields.
|
||||
- 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.
|
||||
- Record the uploaded filename without storing secret values in the audit event.
|
||||
|
||||
The setup upload additionally must:
|
||||
|
||||
- Require the one-time setup code before processing it.
|
||||
- Preserve existing bcrypt administrator password hashes, lockdown roles, and Discord IDs.
|
||||
- Refuse to replace an already-configured database.
|
||||
- Write the configuration, administrators, and audit event atomically.
|
||||
- Record the uploaded filename without storing secret values in the audit event.
|
||||
|
||||
The initialized-server upload additionally must:
|
||||
|
||||
- Require a lockdown administrator with recent password confirmation.
|
||||
- Use optimistic revision checking so it cannot overwrite an intervening edit.
|
||||
- Ignore the entire legacy `admins` collection and leave all current accounts unchanged.
|
||||
- Preserve stored secrets omitted from the file, replace supplied secrets, and clear explicitly empty secrets.
|
||||
- Commit through the normal revision path and immediately reload affected services.
|
||||
|
||||
The browser uploads the selected contents directly. The server never scans the host for a file, and it does not retain, watch, remove, or reuse the uploaded YAML after the database transaction completes.
|
||||
|
||||
@@ -444,6 +454,7 @@ 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.
|
||||
- Added the same explicit legacy YAML picker to the protected Configuration page for replacing an initialized server's configuration. It requires recent lockdown-password confirmation, ignores every YAML administrator entry, filters nonexistent settings, validates current fields, preserves omitted secrets, applies explicitly supplied or empty secrets, uses optimistic revision checking, records the selected filename in audit history, and reloads affected services immediately.
|
||||
- The one-time setup upload now passes its committed configuration through the same live-application coordinator, so a fresh installation does not need an immediate restart after importing YAML.
|
||||
- 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.
|
||||
@@ -464,7 +475,7 @@ Implemented on 2026-09-14:
|
||||
|
||||
Local verification completed:
|
||||
|
||||
- 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.
|
||||
- All 109 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, setup and initialized-server YAML import safety, recursive removal of nonexistent fields, and the earlier filesystem coverage.
|
||||
- All 24 server test files passed after live application was added. The new isolated coordinator test confirms coherent snapshot replacement, top-level change detection, per-service invocation, applied revision reporting, and failure isolation.
|
||||
- Focused admin, route, and identity UI lint passed.
|
||||
- All 20 existing focused web UI tests passed.
|
||||
|
||||
+40
-40
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-DNdP4vNH.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-DoWmWQMy.js.map
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-Dt89YsCl.js";import{e as q,f as D,h as I}from"./api-DxMdZ4GH.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-DfXTyMt5.js.map
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
|
||||
function n(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(r={})=>{if(r?.error){const o=new Error(r.error);o.code=r.code||null,o.validationErrors=r.validationErrors||[],o.currentRevision=r.currentRevision||null,s(o);return}e(r)})})}const d=t=>n(t,"adminConfig:get"),m=(t,i)=>n(t,"adminConfig:confirmPassword",{password:i}),u=(t,i)=>n(t,"adminConfig:updateConfiguration",i),c=(t,i)=>n(t,"adminConfig:restoreRevision",i),f=(t,i)=>n(t,"adminConfig:createAdministrator",i),g=(t,i)=>n(t,"adminConfig:updateAdministrator",i),C=(t,i)=>n(t,"adminConfig:deleteAdministrator",{id:i}),A=t=>n(t,"setup:status"),l=(t,i)=>n(t,"setup:createAdministrator",i),p=(t,i)=>n(t,"setup:importConfigurationFile",i);export{u as a,m as b,f as c,C as d,A as e,l as f,d as g,p as i,c as r,g as u};
|
||||
//# sourceMappingURL=api-C0PIt_OP.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"api-C0PIt_OP.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHU,EAAsB,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQe,IAAOhB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAe,CAAE,CAAE,EAExGC,EAAkBhB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEiB,EAA2B,CAACjB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GgB,EAA0B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
|
||||
@@ -0,0 +1,2 @@
|
||||
function n(i,t,a={}){return new Promise((e,s)=>{i.emit(t,a,(o={})=>{if(o?.error){const r=new Error(o.error);r.code=o.code||null,r.validationErrors=o.validationErrors||[],r.currentRevision=o.currentRevision||null,s(r);return}e(o)})})}const d=i=>n(i,"adminConfig:get"),m=(i,t)=>n(i,"adminConfig:confirmPassword",{password:t}),u=(i,t)=>n(i,"adminConfig:updateConfiguration",t),c=(i,t)=>n(i,"adminConfig:importConfigurationFile",t),f=(i,t)=>n(i,"adminConfig:restoreRevision",t),g=(i,t)=>n(i,"adminConfig:createAdministrator",t),C=(i,t)=>n(i,"adminConfig:updateAdministrator",t),l=(i,t)=>n(i,"adminConfig:deleteAdministrator",{id:t}),A=i=>n(i,"setup:status"),p=(i,t)=>n(i,"setup:createAdministrator",t),v=(i,t)=>n(i,"setup:importConfigurationFile",t);export{u as a,m as b,g as c,l as d,A as e,p as f,d as g,v as h,c as i,f as r,C as u};
|
||||
//# sourceMappingURL=api-DxMdZ4GH.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"api-DxMdZ4GH.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const importAdminConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'adminConfig:importConfigurationFile', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","importAdminConfigurationFile","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,sCAAuCE,CAAO,EAC3HU,EAA+B,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9Ga,EAAsB,CAACf,EAAQgB,IAAOjB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAgB,CAAE,CAAE,EAExGC,EAAkBjB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEkB,EAA2B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GiB,EAA0B,CAACnB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
|
||||
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
File diff suppressed because one or more lines are too long
@@ -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-DNdP4vNH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BhA42K5i.css">
|
||||
<script type="module" crossorigin src="/assets/index-Dt89YsCl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D0j33plX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -11,7 +11,11 @@ const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validat
|
||||
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
|
||||
const { getFeatureFlags } = require('./index');
|
||||
const { createConfigurationDatabase } = require('./database');
|
||||
const { parseConfigurationFile, importConfigurationFile } = require('./configurationFileImporter');
|
||||
const {
|
||||
parseConfigurationFile,
|
||||
buildSecretOperationsForImport,
|
||||
importConfigurationFile,
|
||||
} = require('./configurationFileImporter');
|
||||
|
||||
const temporaryRoots = [];
|
||||
|
||||
@@ -355,6 +359,58 @@ bandwidthSavings:
|
||||
});
|
||||
});
|
||||
|
||||
test('an administrative YAML replacement ignores accounts and only changes secrets present in the file', () => {
|
||||
const database = createTestDatabase();
|
||||
const initial = database.getClientConfiguration();
|
||||
const seededRevision = database.updateConfiguration({
|
||||
value: initial.config,
|
||||
expectedRevision: initial.revision,
|
||||
actor: 'secret-seed',
|
||||
secretOperations: {
|
||||
'homeAssistant.token': { action: 'replace', value: 'preserve-this-token' },
|
||||
'ptzCamera.password': { action: 'replace', value: 'clear-this-password' },
|
||||
'discord.token': { action: 'replace', value: 'replace-this-token' },
|
||||
},
|
||||
});
|
||||
const yamlText = `
|
||||
admins:
|
||||
- this obsolete account entry is deliberately malformed
|
||||
timezone: America/Chicago
|
||||
ptzCamera:
|
||||
password:
|
||||
discord:
|
||||
token: new-discord-token
|
||||
`;
|
||||
|
||||
/*
|
||||
An initialized installation treats the YAML as configuration data only.
|
||||
Even malformed account data is ignored, while presence-aware secret
|
||||
operations preserve an omitted credential, clear an explicit empty value,
|
||||
and replace an explicit non-empty value.
|
||||
*/
|
||||
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
|
||||
assert.deepEqual(parsed.administrators, []);
|
||||
assert.equal(parsed.uploadedAdministratorCount, 1);
|
||||
assert.deepEqual(parsed.providedSecretPaths, ['ptzCamera.password', 'discord.token']);
|
||||
const revision = database.updateConfiguration({
|
||||
value: parsed.config,
|
||||
expectedRevision: seededRevision,
|
||||
secretOperations: buildSecretOperationsForImport(parsed),
|
||||
actor: 'admin-import-test',
|
||||
source: 'admin-yaml:production.yaml',
|
||||
});
|
||||
|
||||
const active = database.getActiveConfigurationRecord();
|
||||
assert.equal(active.revision, revision);
|
||||
assert.equal(active.source, 'admin-yaml:production.yaml');
|
||||
assert.equal(active.config.homeAssistant.token, 'preserve-this-token');
|
||||
assert.equal(active.config.ptzCamera.password, '');
|
||||
assert.equal(active.config.discord.token, 'new-discord-token');
|
||||
assert.equal(active.config.timezone, 'America/Chicago');
|
||||
assert.equal(database.listAdministrators().length, 0);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('committed revisions replace the live snapshot and isolate service reload failures', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-live-configuration-'));
|
||||
temporaryRoots.push(root);
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
// Configuration File Importer
|
||||
// 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.
|
||||
// Purpose: Validates a deliberately uploaded legacy YAML file for first-run setup or an explicit administrative replacement.
|
||||
// Scope: Startup and installation never search for or consume configuration files; every import begins with a browser-selected file.
|
||||
const yaml = require('js-yaml');
|
||||
const { rootSchema, normalizeConfig, assertValidConfig } = require('./validation');
|
||||
const {
|
||||
rootSchema,
|
||||
secretPaths,
|
||||
normalizeConfig,
|
||||
assertValidConfig,
|
||||
} = require('./validation');
|
||||
|
||||
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
function getAtPath(object, dottedPath) {
|
||||
return String(dottedPath || '').split('.').filter(Boolean)
|
||||
.reduce((value, key) => value?.[key], object);
|
||||
}
|
||||
|
||||
function setAtPath(object, dottedPath, value) {
|
||||
const parts = String(dottedPath || '').split('.').filter(Boolean);
|
||||
let cursor = object;
|
||||
parts.slice(0, -1).forEach((key) => {
|
||||
cursor = cursor[key];
|
||||
});
|
||||
cursor[parts.at(-1)] = value;
|
||||
}
|
||||
|
||||
function hasAtPath(object, dottedPath) {
|
||||
/*
|
||||
Presence, rather than truthiness, distinguishes an omitted legacy secret
|
||||
from an explicitly empty one. An omitted credential must preserve the
|
||||
running installation's value, while an empty YAML value deliberately
|
||||
clears it through the same operation used by the schema form.
|
||||
*/
|
||||
let cursor = object;
|
||||
for (const key of String(dottedPath || '').split('.').filter(Boolean)) {
|
||||
if (cursor === null || typeof cursor !== 'object' || !Object.hasOwn(cursor, key)) return false;
|
||||
cursor = cursor[key];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function keepCurrentSchemaFields(value, schema) {
|
||||
/*
|
||||
@@ -46,28 +82,65 @@ function normalizeUploadedAdministrator(entry, index) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfigurationFile(text) {
|
||||
function parseConfigurationFile(text, { includeAdministrators = true } = {}) {
|
||||
const parsed = yaml.load(String(text || ''));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('The configuration file must contain a YAML object.');
|
||||
}
|
||||
|
||||
const administrators = Array.isArray(parsed.admins)
|
||||
? parsed.admins.map(normalizeUploadedAdministrator)
|
||||
const uploadedAdministrators = Array.isArray(parsed.admins) ? parsed.admins : [];
|
||||
/*
|
||||
First-run setup is the sole workflow allowed to create accounts from the
|
||||
legacy file. An initialized server ignores the entire admins collection,
|
||||
including obsolete or malformed entries, so importing configuration can
|
||||
never rename accounts, replace password hashes, or remove the last
|
||||
lockdown administrator.
|
||||
*/
|
||||
const administrators = includeAdministrators
|
||||
? uploadedAdministrators.map(normalizeUploadedAdministrator)
|
||||
: [];
|
||||
const configInput = Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key]) => key !== 'admins'),
|
||||
);
|
||||
secretPaths.forEach((secretPath) => {
|
||||
/*
|
||||
YAML commonly represents `token:` as null even though the application
|
||||
models an unconfigured credential as an empty string. Translate null only
|
||||
for known secret fields so a plainly empty legacy credential has the same
|
||||
clear meaning as the admin form; null in any ordinary current field still
|
||||
fails its schema normally.
|
||||
*/
|
||||
if (hasAtPath(configInput, secretPath) && getAtPath(configInput, secretPath) === null) {
|
||||
setAtPath(configInput, secretPath, '');
|
||||
}
|
||||
});
|
||||
const config = normalizeConfig(keepCurrentSchemaFields(configInput, rootSchema));
|
||||
const providedSecretPaths = secretPaths.filter((secretPath) => hasAtPath(configInput, secretPath));
|
||||
|
||||
// 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.');
|
||||
}
|
||||
return { config, administrators };
|
||||
return {
|
||||
config,
|
||||
administrators,
|
||||
uploadedAdministratorCount: uploadedAdministrators.length,
|
||||
providedSecretPaths,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSecretOperationsForImport({ config, providedSecretPaths = [] }) {
|
||||
return Object.fromEntries(providedSecretPaths.map((secretPath) => {
|
||||
const value = getAtPath(config, secretPath);
|
||||
/*
|
||||
Current secret schemas are strings. Keeping this conversion beside the
|
||||
importer produces the database's narrow replace/clear contract and
|
||||
avoids granting the import route a way around ordinary secret handling.
|
||||
*/
|
||||
return value === ''
|
||||
? [secretPath, { action: 'clear' }]
|
||||
: [secretPath, { action: 'replace', value }];
|
||||
}));
|
||||
}
|
||||
|
||||
function importConfigurationFile({ text, database, actor = 'setup-file-upload', source = 'uploaded-config.yaml' }) {
|
||||
@@ -85,6 +158,8 @@ function importConfigurationFile({ text, database, actor = 'setup-file-upload',
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_CONFIGURATION_FILE_BYTES,
|
||||
parseConfigurationFile,
|
||||
buildSecretOperationsForImport,
|
||||
importConfigurationFile,
|
||||
};
|
||||
|
||||
@@ -161,7 +161,13 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
return { ...record, ...redacted };
|
||||
}
|
||||
|
||||
function updateConfiguration({ value, expectedRevision, secretOperations = {}, actor }) {
|
||||
function updateConfiguration({
|
||||
value,
|
||||
expectedRevision,
|
||||
secretOperations = {},
|
||||
actor,
|
||||
source = 'admin-ui',
|
||||
}) {
|
||||
const active = getActiveConfigurationRecord();
|
||||
const candidate = clone(value);
|
||||
|
||||
@@ -187,7 +193,12 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
return commitRevisionTransaction(candidate, {
|
||||
expectedRevision,
|
||||
actor,
|
||||
source: 'admin-ui',
|
||||
/*
|
||||
Administrative imports use this same safe update path but identify the
|
||||
selected filename in revision and audit history. The source remains
|
||||
server-controlled metadata and never contains configuration values.
|
||||
*/
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ const {
|
||||
applyCommittedConfiguration,
|
||||
rootSchema,
|
||||
} = require('../../configuration');
|
||||
const {
|
||||
MAX_CONFIGURATION_FILE_BYTES,
|
||||
parseConfigurationFile,
|
||||
buildSecretOperationsForImport,
|
||||
} = require('../../configuration/configurationFileImporter');
|
||||
const { getRole } = require('../roleService');
|
||||
|
||||
const PASSWORD_CONFIRMATION_WINDOW_MS = 5 * 60 * 1000;
|
||||
@@ -34,6 +39,18 @@ function actorFor(socket) {
|
||||
return socket?.data?.user?.username || socket.id;
|
||||
}
|
||||
|
||||
function safeUploadedFileName(value) {
|
||||
/*
|
||||
The filename is audit metadata only and is never opened on the server.
|
||||
Removing control characters keeps logs and history readable while
|
||||
retaining the operator-visible name that identifies the imported file.
|
||||
*/
|
||||
return String(value || 'uploaded-config.yaml')
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.trim()
|
||||
.slice(0, 255) || 'uploaded-config.yaml';
|
||||
}
|
||||
|
||||
function errorPayload(error) {
|
||||
return {
|
||||
error: error.message,
|
||||
@@ -102,6 +119,37 @@ io.on('connection', (socket) => {
|
||||
return { revision, application, snapshot: buildAdminSnapshot() };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:importConfigurationFile', requireRecentPassword, async (payload) => {
|
||||
const yamlText = String(payload.yaml || '');
|
||||
if (!yamlText || Buffer.byteLength(yamlText, 'utf8') > MAX_CONFIGURATION_FILE_BYTES) {
|
||||
throw new Error('The YAML configuration file must be present and no larger than 1 MiB.');
|
||||
}
|
||||
|
||||
/*
|
||||
Parsing deliberately excludes administrators on an initialized server.
|
||||
Configuration is still filtered to today's schema and strictly
|
||||
validated, then committed through the ordinary optimistic update path so
|
||||
missing secrets survive and explicitly supplied secrets replace or clear
|
||||
their current values.
|
||||
*/
|
||||
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
|
||||
const fileName = safeUploadedFileName(payload.fileName);
|
||||
const revision = database.updateConfiguration({
|
||||
value: parsed.config,
|
||||
expectedRevision: payload.expectedRevision,
|
||||
secretOperations: buildSecretOperationsForImport(parsed),
|
||||
actor: actorFor(socket),
|
||||
source: `admin-yaml:${fileName}`,
|
||||
});
|
||||
const application = await applyCommittedConfiguration();
|
||||
return {
|
||||
revision,
|
||||
application,
|
||||
ignoredAdministratorCount: parsed.uploadedAdministratorCount,
|
||||
snapshot: buildAdminSnapshot(),
|
||||
};
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:restoreRevision', requireRecentPassword, async (payload) => {
|
||||
const revision = database.restoreConfigurationRevision({
|
||||
revision: payload.revision,
|
||||
|
||||
@@ -6,10 +6,12 @@ const bcrypt = require('bcrypt');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('setupService');
|
||||
const { getConfigurationDatabase, applyCommittedConfiguration } = require('../../configuration');
|
||||
const { importConfigurationFile } = require('../../configuration/configurationFileImporter');
|
||||
const {
|
||||
MAX_CONFIGURATION_FILE_BYTES,
|
||||
importConfigurationFile,
|
||||
} = require('../../configuration/configurationFileImporter');
|
||||
const { createSetupCodeFile } = require('./setupCodeFile');
|
||||
|
||||
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
|
||||
const database = getConfigurationDatabase();
|
||||
const setupCodeFile = createSetupCodeFile();
|
||||
let setupNoticeLogged = false;
|
||||
|
||||
@@ -20,6 +20,7 @@ export function emitAdminRequest(socket, eventName, payload = {}) {
|
||||
export const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');
|
||||
export const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });
|
||||
export const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);
|
||||
export const importAdminConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'adminConfig:importConfigurationFile', payload);
|
||||
export const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);
|
||||
export const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);
|
||||
export const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Scope: Edits and saves one complete configuration document as one immutable revision.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import CardFrame from '../../components/CardFrame/index.jsx';
|
||||
import { updateConfiguration } from '../api.js';
|
||||
import { importAdminConfigurationFile, updateConfiguration } from '../api.js';
|
||||
import SchemaConfigurationForm from './SchemaConfigurationForm.jsx';
|
||||
|
||||
function clone(value) {
|
||||
@@ -17,6 +17,9 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
|
||||
const [draft, setDraft] = useState(() => clone(serverValue));
|
||||
const [secretOperations, setSecretOperations] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [configurationFile, setConfigurationFile] = useState(null);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [validationErrors, setValidationErrors] = useState([]);
|
||||
|
||||
@@ -24,6 +27,7 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
|
||||
setDraft(clone(serverValue));
|
||||
setSecretOperations({});
|
||||
setError('');
|
||||
setConfigurationFile(null);
|
||||
setValidationErrors([]);
|
||||
}, [revision, serverValue]);
|
||||
|
||||
@@ -31,6 +35,7 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
|
||||
() => JSON.stringify(draft) !== JSON.stringify(serverValue) || Object.keys(secretOperations).length > 0,
|
||||
[draft, secretOperations, serverValue],
|
||||
);
|
||||
const busy = saving || importing;
|
||||
|
||||
if (!serverValue || !schema) {
|
||||
return <p className="surface p-1 text-sm text-red-200">The configuration document is unavailable.</p>;
|
||||
@@ -62,6 +67,42 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
|
||||
}
|
||||
}
|
||||
|
||||
async function importSelectedConfiguration() {
|
||||
if (!configurationFile) return;
|
||||
/*
|
||||
Import replaces the complete draft and applies it immediately, so the
|
||||
confirmation names both consequences before reading or transmitting the
|
||||
operator-selected file. Administrator accounts remain owned by the
|
||||
initialized database and are never imported from this screen.
|
||||
*/
|
||||
const confirmed = window.confirm(
|
||||
'Replace the current configuration with this YAML file and apply it now? Any unsaved edits will be discarded. Administrator accounts in the file will be ignored.',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setImporting(true);
|
||||
setError('');
|
||||
setNotice('');
|
||||
setValidationErrors([]);
|
||||
try {
|
||||
const yamlText = await configurationFile.text();
|
||||
const response = await runSensitive(() => importAdminConfigurationFile(socket, {
|
||||
fileName: configurationFile.name,
|
||||
yaml: yamlText,
|
||||
expectedRevision: revision,
|
||||
}));
|
||||
onSnapshot(response.snapshot);
|
||||
setNotice(response.ignoredAdministratorCount > 0
|
||||
? `Configuration imported and applied. ${response.ignoredAdministratorCount} administrator entr${response.ignoredAdministratorCount === 1 ? 'y was' : 'ies were'} ignored.`
|
||||
: 'Configuration imported and applied.');
|
||||
} catch (importError) {
|
||||
setError(importError.message);
|
||||
setValidationErrors(importError.validationErrors || []);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -70,14 +111,33 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
|
||||
editor may use a wide canvas, but width is never used to separate a
|
||||
control from the content that explains it. */}
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
<button type="button" className="button-dark" disabled={saving} onClick={onReload}>Reload</button>
|
||||
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={() => {
|
||||
<button type="button" className="button-dark" disabled={busy} onClick={onReload}>Reload</button>
|
||||
<button type="button" className="button-dark" disabled={!dirty || busy} onClick={() => {
|
||||
setDraft(clone(serverValue));
|
||||
setSecretOperations({});
|
||||
}}>Reset</button>
|
||||
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={save}>{saving ? 'Applying…' : 'Save configuration'}</button>
|
||||
<button type="button" className="button-dark" disabled={!dirty || busy} onClick={save}>{saving ? 'Applying…' : 'Save configuration'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<CardFrame title="Import legacy YAML" bodyClassName="space-y-0.5 p-1 text-sm" clipOverflow={false}>
|
||||
<p className="text-sm text-slate-300">Replace this configuration from an explicitly selected legacy file. Unknown old settings and administrator accounts are ignored; current settings are validated and applied immediately.</p>
|
||||
{/* Keep the picker and its action beside each other at the start of the
|
||||
card. The configuration canvas can be wide, but this local action
|
||||
should never be separated from the file it operates on. */}
|
||||
<div className="flex max-w-3xl flex-col gap-0.5 md:flex-row">
|
||||
<input
|
||||
key={revision}
|
||||
className="field-input min-w-0 flex-1"
|
||||
type="file"
|
||||
accept=".yaml,.yml,text/yaml"
|
||||
disabled={busy}
|
||||
onChange={(event) => setConfigurationFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
<button type="button" className="button-dark" disabled={busy || !configurationFile} onClick={importSelectedConfiguration}>
|
||||
{importing ? 'Importing…' : 'Import selected YAML'}
|
||||
</button>
|
||||
</div>
|
||||
</CardFrame>
|
||||
{snapshot.configurationApplication?.services?.some((service) => service.status === 'failed') ? (
|
||||
<div className="mb-0.5 border border-amber-500/60 bg-amber-950/40 p-1 text-xs text-amber-100">
|
||||
<p className="font-semibold">Configuration was saved, but some services could not reload</p>
|
||||
@@ -89,6 +149,7 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <p className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">{error}</p> : null}
|
||||
{notice ? <p className="border border-emerald-500/60 bg-emerald-950/40 p-1 text-sm text-emerald-100">{notice}</p> : null}
|
||||
{validationErrors.length ? (
|
||||
<div className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">
|
||||
<p className="font-semibold">Configuration could not be saved</p>
|
||||
|
||||
Reference in New Issue
Block a user