Compare commits
9
Commits
2b4f4f034d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a95131a5 | ||
|
|
28f5fda451 | ||
|
|
dcdc437efa | ||
|
|
6393543bf5 | ||
|
|
3c34b708b9 | ||
|
|
d4b9d0870e | ||
|
|
fe1e314df9 | ||
|
|
9a2a4ab72e | ||
|
|
181f55a849 |
No files matched your search
@@ -379,6 +379,22 @@ Copy `.env.example` to `.env` and fill in. Summary:
|
|||||||
| `OCPP_PUBLIC_URL` | — | canonical `ws(s)://` base to point chargers at |
|
| `OCPP_PUBLIC_URL` | — | canonical `ws(s)://` base to point chargers at |
|
||||||
| `PB_BOOTSTRAP` | `true` | run the on-boot schema create/reconcile (leave on across upgrades) |
|
| `PB_BOOTSTRAP` | `true` | run the on-boot schema create/reconcile (leave on across upgrades) |
|
||||||
| `DRIVERVAULT_SUPERADMIN_EMAIL` / `_PASSWORD` / `_NAME` | — / — / `Administrator` | first `superadmin`, created on boot when absent |
|
| `DRIVERVAULT_SUPERADMIN_EMAIL` / `_PASSWORD` / `_NAME` | — / — / `Administrator` | first `superadmin`, created on boot when absent |
|
||||||
|
| `PB_S3_ENABLED` | `false` | keep PocketBase's record files in an S3 bucket instead of on its own volume |
|
||||||
|
| `PB_S3_BUCKET` | `drivervault` | the bucket; it must already exist |
|
||||||
|
| `PB_S3_ENDPOINT` | — | e.g. `http://seaweedfs:8333`. No default: in-stack and external gateways are different addresses |
|
||||||
|
| `PB_S3_REGION` | `us-east-1` | SeaweedFS ignores it, PocketBase insists on one |
|
||||||
|
| `PB_S3_ACCESS_KEY` / `PB_S3_SECRET` | — | S3 credentials |
|
||||||
|
| `PB_S3_FORCE_PATH_STYLE` | `true` | path-style bucket addressing; `false` for AWS S3 proper |
|
||||||
|
|
||||||
|
The `PB_S3_*` block is applied by the same on-boot bootstrap that creates the
|
||||||
|
collections, and only when **all** of bucket, endpoint and credentials are set —
|
||||||
|
a half-filled config logs a warning and leaves uploads on the local volume. It
|
||||||
|
writes PocketBase's *Files storage* settings and nothing else: backups stay
|
||||||
|
where they are, and it never turns S3 back *off*, since files already in a bucket
|
||||||
|
are reachable only while PocketBase still points at it. Attachments are served
|
||||||
|
through this server either way ([`internal/api/attachments.go`](internal/api/attachments.go)),
|
||||||
|
so no client can tell the difference. See [`../Docker`](../Docker) for the compose
|
||||||
|
files that set these.
|
||||||
|
|
||||||
`PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD`, `PORT` and `CORS_ORIGINS` are
|
`PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD`, `PORT` and `CORS_ORIGINS` are
|
||||||
still honoured for older deployments; the modern names win when both are set.
|
still honoured for older deployments; the modern names win when both are set.
|
||||||
|
|||||||
@@ -51,12 +51,31 @@ func main() {
|
|||||||
// bad service account, must not stop the panel from coming up so a superadmin
|
// bad service account, must not stop the panel from coming up so a superadmin
|
||||||
// can log in and fix the connection.
|
// can log in and fix the connection.
|
||||||
if cfg.Bootstrap && cfg.AdminConfigured() {
|
if cfg.Bootstrap && cfg.AdminConfigured() {
|
||||||
|
// Record files go to S3 only when the whole bucket is described. Asking
|
||||||
|
// for it and leaving half of it blank is a misconfiguration worth saying
|
||||||
|
// out loud, not a reason to point PocketBase at nowhere.
|
||||||
|
var s3 *bootstrap.S3Options
|
||||||
|
if cfg.StorageConfigured() {
|
||||||
|
s3 = &bootstrap.S3Options{
|
||||||
|
Enabled: true,
|
||||||
|
Bucket: cfg.S3Bucket,
|
||||||
|
Region: cfg.S3Region,
|
||||||
|
Endpoint: cfg.S3Endpoint,
|
||||||
|
AccessKey: cfg.S3AccessKey,
|
||||||
|
Secret: cfg.S3Secret,
|
||||||
|
ForcePathStyle: cfg.S3ForcePathStyle,
|
||||||
|
}
|
||||||
|
} else if cfg.S3Enabled {
|
||||||
|
log.Println("WARNING: PB_S3_ENABLED is set but the bucket, endpoint or credentials are incomplete — file storage stays on the local volume")
|
||||||
|
}
|
||||||
|
|
||||||
bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
if err := bootstrap.Run(bootCtx, client, bootstrap.Options{
|
if err := bootstrap.Run(bootCtx, client, bootstrap.Options{
|
||||||
UsersCollection: cfg.UsersCollection,
|
UsersCollection: cfg.UsersCollection,
|
||||||
SuperAdminEmail: cfg.SuperAdminEmail,
|
SuperAdminEmail: cfg.SuperAdminEmail,
|
||||||
SuperAdminPassword: cfg.SuperAdminPassword,
|
SuperAdminPassword: cfg.SuperAdminPassword,
|
||||||
SuperAdminName: cfg.SuperAdminName,
|
SuperAdminName: cfg.SuperAdminName,
|
||||||
|
S3: s3,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("WARNING: bootstrap failed: %v", err)
|
log.Printf("WARNING: bootstrap failed: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const r of o.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&n(r)}).observe(document,{childList:!0,subtree:!0});function s(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function n(i){if(i.ep)return;i.ep=!0;const o=s(i);fetch(i.href,o)}})();/**
|
|
||||||
* @vue/shared v3.5.39
|
|
||||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
|
||||||
* @license MIT
|
|
||||||
**/function cn(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const ne={},Tt=[],Ze=()=>{},mi=()=>!1,Ss=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Es=e=>e.startsWith("onUpdate:"),_e=Object.assign,un=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},$o=Object.prototype.hasOwnProperty,X=(e,t)=>$o.call(e,t),L=Array.isArray,St=e=>ts(e)==="[object Map]",zt=e=>ts(e)==="[object Set]",zn=e=>ts(e)==="[object Date]",W=e=>typeof e=="function",ue=e=>typeof e=="string",Je=e=>typeof e=="symbol",te=e=>e!==null&&typeof e=="object",bi=e=>(te(e)||W(e))&&W(e.then)&&W(e.catch),vi=Object.prototype.toString,ts=e=>vi.call(e),Oo=e=>ts(e).slice(8,-1),yi=e=>ts(e)==="[object Object]",dn=e=>ue(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Bt=cn(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Ps=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},Do=/-\w/g,je=Ps(e=>e.replace(Do,t=>t.slice(1).toUpperCase())),Ro=/\B([A-Z])/g,ft=Ps(e=>e.replace(Ro,"-$1").toLowerCase()),_i=Ps(e=>e.charAt(0).toUpperCase()+e.slice(1)),Ns=Ps(e=>e?`on${_i(e)}`:""),qe=(e,t)=>!Object.is(e,t),ps=(e,...t)=>{for(let s=0;s<e.length;s++)e[s](...t)},wi=(e,t,s,n=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},Cs=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let In;const As=()=>In||(In=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function pn(e){if(L(e)){const t={};for(let s=0;s<e.length;s++){const n=e[s],i=ue(n)?Uo(n):pn(n);if(i)for(const o in i)t[o]=i[o]}return t}else if(ue(e)||te(e))return e}const zo=/;(?![^(]*\))/g,Io=/:([^]+)/,jo=/\/\*[^]*?\*\//g;function Uo(e){const t={};return e.replace(jo,"").split(zo).forEach(s=>{if(s){const n=s.split(Io);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function Ee(e){let t="";if(ue(e))t=e;else if(L(e))for(let s=0;s<e.length;s++){const n=Ee(e[s]);n&&(t+=n+" ")}else if(te(e))for(const s in e)e[s]&&(t+=s+" ");return t.trim()}const No="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Mo=cn(No);function xi(e){return!!e||e===""}function Lo(e,t){if(e.length!==t.length)return!1;let s=!0;for(let n=0;s&&n<e.length;n++)s=ut(e[n],t[n]);return s}function ut(e,t){if(e===t)return!0;let s=zn(e),n=zn(t);if(s||n)return s&&n?e.getTime()===t.getTime():!1;if(s=Je(e),n=Je(t),s||n)return e===t;if(s=L(e),n=L(t),s||n)return s&&n?Lo(e,t):!1;if(s=te(e),n=te(t),s||n){if(!s||!n)return!1;const i=Object.keys(e).length,o=Object.keys(t).length;if(i!==o)return!1;for(const r in e){const a=e.hasOwnProperty(r),l=t.hasOwnProperty(r);if(a&&!l||!a&&l||!ut(e[r],t[r]))return!1}}return String(e)===String(t)}function fn(e,t){return e.findIndex(s=>ut(s,t))}const ki=e=>!!(e&&e.__v_isRef===!0),m=e=>ue(e)?e:e==null?"":L(e)||te(e)&&(e.toString===vi||!W(e.toString))?ki(e)?m(e.value):JSON.stringify(e,Ti,2):String(e),Ti=(e,t)=>ki(t)?Ti(e,t.value):St(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,i],o)=>(s[Ms(n,o)+" =>"]=i,s),{})}:zt(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Ms(s))}:Je(t)?Ms(t):te(t)&&!L(t)&&!yi(t)?String(t):t,Ms=(e,t="")=>{var s;return Je(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/**
|
|
||||||
* @vue/reactivity v3.5.39
|
|
||||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
|
||||||
* @license MIT
|
|
||||||
**/let me;class Fo{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&me&&(me.active?(this.parent=me,this.index=(me.scopes||(me.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].pause();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].resume();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].resume()}}run(t){if(this._active){const s=me;try{return me=this,t()}finally{me=s}}}on(){++this._on===1&&(this.prevScope=me,me=this)}off(){if(this._on>0&&--this._on===0){if(me===this)me=this.prevScope;else{let t=me;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s<n;s++)this.effects[s].stop();for(this.effects.length=0,s=0,n=this.cleanups.length;s<n;s++)this.cleanups[s]();if(this.cleanups.length=0,this.scopes){for(s=0,n=this.scopes.length;s<n;s++)this.scopes[s].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const i=this.parent.scopes.pop();i&&i!==this&&(this.parent.scopes[this.index]=i,i.index=this.index)}this.parent=void 0}}}function Bo(){return me}let oe;const Ls=new WeakSet;class Si{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,me&&(me.active?me.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,Ls.has(this)&&(Ls.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||Pi(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,jn(this),Ci(this);const t=oe,s=Ue;oe=this,Ue=!0;try{return this.fn()}finally{Ai(this),oe=t,Ue=s,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)mn(t);this.deps=this.depsTail=void 0,jn(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?Ls.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Js(this)&&this.run()}get dirty(){return Js(this)}}let Ei=0,Ht,Vt;function Pi(e,t=!1){if(e.flags|=8,t){e.next=Vt,Vt=e;return}e.next=Ht,Ht=e}function hn(){Ei++}function gn(){if(--Ei>0)return;if(Vt){let t=Vt;for(Vt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Ht;){let t=Ht;for(Ht=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function Ci(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Ai(e){let t,s=e.depsTail,n=s;for(;n;){const i=n.prevDep;n.version===-1?(n===s&&(s=i),mn(n),Ho(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=i}e.deps=t,e.depsTail=s}function Js(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&($i(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function $i(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Zt)||(e.globalVersion=Zt,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Js(e))))return;e.flags|=2;const t=e.dep,s=oe,n=Ue;oe=e,Ue=!0;try{Ci(e);const i=e.fn(e._value);(t.version===0||qe(i,e._value))&&(e.flags|=128,e._value=i,t.version++)}catch(i){throw t.version++,i}finally{oe=s,Ue=n,Ai(e),e.flags&=-3}}function mn(e,t=!1){const{dep:s,prevSub:n,nextSub:i}=e;if(n&&(n.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let o=s.computed.deps;o;o=o.nextDep)mn(o,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function Ho(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let Ue=!0;const Oi=[];function Ye(){Oi.push(Ue),Ue=!1}function Xe(){const e=Oi.pop();Ue=e===void 0?!0:e}function jn(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=oe;oe=void 0;try{t()}finally{oe=s}}}let Zt=0;class Vo{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class bn{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!oe||!Ue||oe===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==oe)s=this.activeLink=new Vo(oe,this),oe.deps?(s.prevDep=oe.depsTail,oe.depsTail.nextDep=s,oe.depsTail=s):oe.deps=oe.depsTail=s,Di(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=oe.depsTail,s.nextDep=void 0,oe.depsTail.nextDep=s,oe.depsTail=s,oe.deps===s&&(oe.deps=n)}return s}trigger(t){this.versiLine truncated
|
|
||||||
* @vue/runtime-core v3.5.39
|
|
||||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
|
||||||
* @license MIT
|
|
||||||
**/function ss(e,t,s,n){try{return n?e(...n):e()}catch(i){Os(i,t,s)}}function Me(e,t,s,n){if(W(e)){const i=ss(e,t,s,n);return i&&bi(i)&&i.catch(o=>{Os(o,t,s)}),i}if(L(e)){const i=[];for(let o=0;o<e.length;o++)i.push(Me(e[o],t,s,n));return i}}function Os(e,t,s,n=!0){const i=t?t.vnode:null,{errorHandler:o,throwUnhandledErrorInProduction:r}=t&&t.appContext.config||ne;if(t){let a=t.parent;const l=t.proxy,g=`https://vuejs.org/error-reference/#runtime-${s}`;for(;a;){const d=a.ec;if(d){for(let b=0;b<d.length;b++)if(d[b](e,l,g)===!1)return}a=a.parent}if(o){Ye(),ss(o,null,10,[e,l,g]),Xe();return}}gr(e,s,i,n,r)}function gr(e,t,s,n=!0,i=!1){if(i)throw e;console.error(e)}const ke=[];let Ge=-1;const Et=[];let ct=null,kt=0;const Li=Promise.resolve();let bs=null;function Fi(e){const t=bs||Li;return e?t.then(this?e.bind(this):e):t}function mr(e){let t=Ge+1,s=ke.length;for(;t<s;){const n=t+s>>>1,i=ke[n],o=Xt(i);o<e||o===e&&i.flags&2?t=n+1:s=n}return t}function wn(e){if(!(e.flags&1)){const t=Xt(e),s=ke[ke.length-1];!s||!(e.flags&2)&&t>=Xt(s)?ke.push(e):ke.splice(mr(t),0,e),e.flags|=1,Bi()}}function Bi(){bs||(bs=Li.then(Vi))}function br(e){L(e)?Et.push(...e):ct&&e.id===-1?ct.splice(kt+1,0,e):e.flags&1||(Et.push(e),e.flags|=1),Bi()}function Nn(e,t,s=Ge+1){for(;s<ke.length;s++){const n=ke[s];if(n&&n.flags&2){if(e&&n.id!==e.uid)continue;ke.splice(s,1),s--,n.flags&4&&(n.flags&=-2),n(),n.flags&4||(n.flags&=-2)}}}function Hi(e){if(Et.length){const t=[...new Set(Et)].sort((s,n)=>Xt(s)-Xt(n));if(Et.length=0,ct){ct.push(...t);return}for(ct=t,kt=0;kt<ct.length;kt++){const s=ct[kt];s.flags&4&&(s.flags&=-2),s.flags&8||s(),s.flags&=-2}ct=null,kt=0}}const Xt=e=>e.id==null?e.flags&2?-1:1/0:e.id;function Vi(e){try{for(Ge=0;Ge<ke.length;Ge++){const t=ke[Ge];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),ss(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;Ge<ke.length;Ge++){const t=ke[Ge];t&&(t.flags&=-2)}Ge=-1,ke.length=0,Hi(),bs=null,(ke.length||Et.length)&&Vi()}}let Re=null,Gi=null;function vs(e){const t=Re;return Re=e,Gi=e&&e.type.__scopeId||null,t}function vr(e,t=Re,s){if(!t||e._n)return e;const n=(...i)=>{n._d&&Zn(-1);const o=vs(t);let r;try{r=e(...i)}finally{vs(o),n._d&&Zn(1)}return r};return n._n=!0,n._c=!0,n._d=!0,n}function pe(e,t){if(Re===null)return e;const s=Is(Re),n=e.dirs||(e.dirs=[]);for(let i=0;i<t.length;i++){let[o,r,a,l=ne]=t[i];o&&(W(o)&&(o={mounted:o,updated:o}),o.deep&&it(r),n.push({dir:o,instance:s,value:r,oldValue:void 0,arg:a,modifiers:l}))}return e}function mt(e,t,s,n){const i=e.dirs,o=t&&t.dirs;for(let r=0;r<i.length;r++){const a=i[r];o&&(a.oldValue=o[r].value);let l=a.dir[n];l&&(Ye(),Me(l,s,8,[e.el,a,e,t]),Xe())}}function yr(e,t){if(Te){let s=Te.provides;const n=Te.parent&&Te.parent.provides;n===s&&(s=Te.provides=Object.create(n)),s[e]=t}}function fs(e,t,s=!1){const n=ma();if(n||Pt){let i=Pt?Pt._context.provides:n?n.parent==null||n.ce?n.vnode.appContext&&n.vnode.appContext.provides:n.parent.provides:void 0;if(i&&e in i)return i[e];if(arguments.length>1)return s&&W(t)?t.call(n&&n.proxy):t}}const _r=Symbol.for("v-scx"),wr=()=>fs(_r);function Hs(e,t,s){return Ki(e,t,s)}function Ki(e,t,s=ne){const{immediate:n,deep:i,flush:o,once:r}=s,a=_e({},s),l=t&&n||!t&&o!=="post";let g;if(es){if(o==="sync"){const A=wr();g=A.__watcherHandles||(A.__watcherHandles=[])}else if(!l){const A=()=>{};return A.stop=Ze,A.resume=Ze,A.pause=Ze,A}}const d=Te;a.call=(A,V,x)=>Me(A,d,V,x);let b=!1;o==="post"?a.scheduler=A=>{Se(A,d&&d.suspense)}:o!=="sync"&&(b=!0,a.scheduler=(A,V)=>{V?A():wn(A)}),a.augmentJob=A=>{t&&(A.flags|=4),b&&(A.flags|=2,d&&(A.id=d.uid,A.i=d))};const E=hr(e,t,a);return es&&(g?g.push(E):l&&E()),E}function xr(e,t,s){const n=this.proxy,i=ue(e)?e.includes(".")?Wi(n,e):()=>n[e]:e.bind(n,n);let o;W(t)?o=t:(o=t.handler,s=t);const r=ns(this),a=Ki(i,o.bind(n),s);return r(),a}function Wi(e,t){const s=t.split(".");return()=>{let n=e;for(let i=0;i<s.length&&n;i++)n=n[s[i]];return n}}const kr=Symbol("_vte"),Tr=e=>e.__isTeleport,Vs=Symbol("_leaveCb");function xn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,xn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function qi(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function Mn(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const ys=new WeakMap;function Gt(e,t,s,n,i=!1){if(L(e)){e.forEach((x,N)=>Gt(x,t&&(L(t)?t[N]:t),s,n,i));return}if(Kt(n)&&!i){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&Gt(e,t,s,n.component.subTree);return}const o=n.shapeFlag&4?Is(n.component):n.el,r=i?null:o,{i:a,r:l}=e,g=t&&t.r,d=a.refs===ne?a.refs={}:a.refs,b=a.setupState,E=Y(b),A=b===ne?mi:x=>Mn(d,x)?!1:X(E,x),V=(x,N)=>!(N&&Mn(d,N));if(g!=null&&g!==l){if(Ln(t),ue(g))d[g]=null,A(g)&&(b[g]=null);else if(ye(g)){const x=t;V(g,x.k)&&(g.value=null),x.k&&(d[x.k]=null)}}if(W(l)){Ye();try{ss(l,a,12,[r,d])}finally{Xe()}}else{const x=ue(l),N=ye(l);if(x||N){const R=()=>{if(e.f
Line truncated
|
|
||||||
* @vue/runtime-dom v3.5.39
|
|
||||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
|
||||||
* @license MIT
|
|
||||||
**/let an;const Xn=typeof window<"u"&&window.trustedTypes;if(Xn)try{an=Xn.createPolicy("vue",{createHTML:e=>e})}catch{}const _o=an?e=>an.createHTML(e):e=>e,ka="http://www.w3.org/2000/svg",Ta="http://www.w3.org/1998/Math/MathML",tt=typeof document<"u"?document:null,Qn=tt&&tt.createElement("template"),Sa={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const i=t==="svg"?tt.createElementNS(ka,e):t==="mathml"?tt.createElementNS(Ta,e):s?tt.createElement(e,{is:s}):tt.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>tt.createTextNode(e),createComment:e=>tt.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>tt.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,i,o){const r=s?s.previousSibling:t.lastChild;if(i&&(i===o||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),s),!(i===o||!(i=i.nextSibling)););else{Qn.innerHTML=_o(n==="svg"?`<svg>${e}</svg>`:n==="mathml"?`<math>${e}</math>`:e);const a=Qn.content;if(n==="svg"||n==="mathml"){const l=a.firstChild;for(;l.firstChild;)a.appendChild(l.firstChild);a.removeChild(l)}t.insertBefore(a,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},Ea=Symbol("_vtc");function Pa(e,t,s){const n=e[Ea];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const ei=Symbol("_vod"),Ca=Symbol("_vsh"),Aa=Symbol(""),$a=/(?:^|;)\s*display\s*:/;function Oa(e,t,s){const n=e.style,i=ue(s);let o=!1;if(s&&!i){if(t)if(ue(t))for(const r of t.split(";")){const a=r.slice(0,r.indexOf(":")).trim();s[a]==null&&Ft(n,a,"")}else for(const r in t)s[r]==null&&Ft(n,r,"");for(const r in s){r==="display"&&(o=!0);const a=s[r];a!=null?Ra(e,r,!ue(t)&&t?t[r]:void 0,a)||Ft(n,r,a):Ft(n,r,"")}}else if(i){if(t!==s){const r=n[Aa];r&&(s+=";"+r),n.cssText=s,o=$a.test(s)}}else t&&e.removeAttribute("style");ei in e&&(e[ei]=o?n.display:"",e[Ca]&&(n.display="none"))}const ti=/\s*!important$/;function Ft(e,t,s){if(L(s))s.forEach(n=>Ft(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=Da(e,t);ti.test(s)?e.setProperty(ft(n),s.replace(ti,""),"important"):e[n]=s}}const si=["Webkit","Moz","ms"],qs={};function Da(e,t){const s=qs[t];if(s)return s;let n=je(t);if(n!=="filter"&&n in e)return qs[t]=n;n=_i(n);for(let i=0;i<si.length;i++){const o=si[i]+n;if(o in e)return qs[t]=o}return t}function Ra(e,t,s,n){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&ue(n)&&s===n}const ni="http://www.w3.org/1999/xlink";function ii(e,t,s,n,i,o=Mo(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(ni,t.slice(6,t.length)):e.setAttributeNS(ni,t,s):s==null||o&&!xi(s)?e.removeAttribute(t):e.setAttribute(t,o?"":Je(s)?String(s):s)}function oi(e,t,s,n,i){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?_o(s):s);return}const o=e.tagName;if(t==="value"&&o!=="PROGRESS"&&!o.includes("-")){const a=o==="OPTION"?e.getAttribute("value")||"":e.value,l=s==null?e.type==="checkbox"?"on":"":String(s);(a!==l||!("_value"in e))&&(e.value=l),s==null&&e.removeAttribute(t),e._value=s;return}let r=!1;if(s===""||s==null){const a=typeof e[t];a==="boolean"?s=xi(s):s==null&&a==="string"?(s="",r=!0):a==="number"&&(s=0,r=!0)}try{e[t]=s}catch{}r&&e.removeAttribute(i||t)}function ot(e,t,s,n){e.addEventListener(t,s,n)}function za(e,t,s,n){e.removeEventListener(t,s,n)}const ri=Symbol("_vei");function Ia(e,t,s,n,i=null){const o=e[ri]||(e[ri]={}),r=o[t];if(n&&r)r.value=n;else{const[a,l]=Na(t);if(n){const g=o[t]=Fa(n,i);ot(e,a,g,l)}else r&&(za(e,a,r,l),o[t]=void 0)}}const ja=/(Once|Passive|Capture)$/,Ua=/^on:?(?:Once|Passive|Capture)$/;function Na(e){let t,s;for(;(s=e.match(ja))&&!Ua.test(e);)t||(t={}),e=e.slice(0,e.length-s[1].length),t[s[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):ft(e.slice(2)),t]}let Zs=0;const Ma=Promise.resolve(),La=()=>Zs||(Ma.then(()=>Zs=0),Zs=Date.now());function Fa(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const i=s.value;if(L(i)){const o=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{o.call(n),n._stopped=!0};const r=i.slice(),a=[n];for(let l=0;l<r.length&&!n._stopped;l++){const g=r[l];g&&Me(g,t,5,a)}}else Me(i,t,5,[n])};return s.value=e,s.attached=La(),s}const ai=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Ba=(e,t,s,n,i,o)=>{const r=i==="svg";t==="class"?Pa(e,n,r):t==="style"?Oa(e,s,n):Ss(t)?Es(t)||Ia(e,t,s,n,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Ha(e,t,n,r))?(oi(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&ii(e,t,n,r,o,t!=="value")):e._isVueCE&&(Va(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ue(n)))?oi(e,je(t),n,o,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._fals
Line truncated
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const r of o.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&n(r)}).observe(document,{childList:!0,subtree:!0});function s(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function n(i){if(i.ep)return;i.ep=!0;const o=s(i);fetch(i.href,o)}})();/**
|
||||||
|
* @vue/shared v3.5.39
|
||||||
|
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||||
|
* @license MIT
|
||||||
|
**/function un(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const ne={},Pt=[],Xe=()=>{},mi=()=>!1,Ps=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Cs=e=>e.startsWith("onUpdate:"),xe=Object.assign,dn=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},$o=Object.prototype.hasOwnProperty,X=(e,t)=>$o.call(e,t),F=Array.isArray,Ct=e=>is(e)==="[object Map]",Ut=e=>is(e)==="[object Set]",zn=e=>is(e)==="[object Date]",W=e=>typeof e=="function",de=e=>typeof e=="string",Qe=e=>typeof e=="symbol",te=e=>e!==null&&typeof e=="object",bi=e=>(te(e)||W(e))&&W(e.then)&&W(e.catch),vi=Object.prototype.toString,is=e=>vi.call(e),Oo=e=>is(e).slice(8,-1),yi=e=>is(e)==="[object Object]",pn=e=>de(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Gt=un(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),As=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},Do=/-\w/g,Ne=As(e=>e.replace(Do,t=>t.slice(1).toUpperCase())),Ro=/\B([A-Z])/g,bt=As(e=>e.replace(Ro,"-$1").toLowerCase()),_i=As(e=>e.charAt(0).toUpperCase()+e.slice(1)),Ns=As(e=>e?`on${_i(e)}`:""),Ye=(e,t)=>!Object.is(e,t),hs=(e,...t)=>{for(let s=0;s<e.length;s++)e[s](...t)},wi=(e,t,s,n=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},$s=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let In;const Os=()=>In||(In=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function fn(e){if(F(e)){const t={};for(let s=0;s<e.length;s++){const n=e[s],i=de(n)?Uo(n):fn(n);if(i)for(const o in i)t[o]=i[o]}return t}else if(de(e)||te(e))return e}const zo=/;(?![^(]*\))/g,Io=/:([^]+)/,jo=/\/\*[^]*?\*\//g;function Uo(e){const t={};return e.replace(jo,"").split(zo).forEach(s=>{if(s){const n=s.split(Io);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function $e(e){let t="";if(de(e))t=e;else if(F(e))for(let s=0;s<e.length;s++){const n=$e(e[s]);n&&(t+=n+" ")}else if(te(e))for(const s in e)e[s]&&(t+=s+" ");return t.trim()}const Mo="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",No=un(Mo);function xi(e){return!!e||e===""}function Lo(e,t){if(e.length!==t.length)return!1;let s=!0;for(let n=0;s&&n<e.length;n++)s=ht(e[n],t[n]);return s}function ht(e,t){if(e===t)return!0;let s=zn(e),n=zn(t);if(s||n)return s&&n?e.getTime()===t.getTime():!1;if(s=Qe(e),n=Qe(t),s||n)return e===t;if(s=F(e),n=F(t),s||n)return s&&n?Lo(e,t):!1;if(s=te(e),n=te(t),s||n){if(!s||!n)return!1;const i=Object.keys(e).length,o=Object.keys(t).length;if(i!==o)return!1;for(const r in e){const a=e.hasOwnProperty(r),l=t.hasOwnProperty(r);if(a&&!l||!a&&l||!ht(e[r],t[r]))return!1}}return String(e)===String(t)}function hn(e,t){return e.findIndex(s=>ht(s,t))}const ki=e=>!!(e&&e.__v_isRef===!0),m=e=>de(e)?e:e==null?"":F(e)||te(e)&&(e.toString===vi||!W(e.toString))?ki(e)?m(e.value):JSON.stringify(e,Ti,2):String(e),Ti=(e,t)=>ki(t)?Ti(e,t.value):Ct(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,i],o)=>(s[Ls(n,o)+" =>"]=i,s),{})}:Ut(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Ls(s))}:Qe(t)?Ls(t):te(t)&&!F(t)&&!yi(t)?String(t):t,Ls=(e,t="")=>{var s;return Qe(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/**
|
||||||
|
* @vue/reactivity v3.5.39
|
||||||
|
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||||
|
* @license MIT
|
||||||
|
**/let ve;class Fo{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&ve&&(ve.active?(this.parent=ve,this.index=(ve.scopes||(ve.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].pause();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].resume();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].resume()}}run(t){if(this._active){const s=ve;try{return ve=this,t()}finally{ve=s}}}on(){++this._on===1&&(this.prevScope=ve,ve=this)}off(){if(this._on>0&&--this._on===0){if(ve===this)ve=this.prevScope;else{let t=ve;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s<n;s++)this.effects[s].stop();for(this.effects.length=0,s=0,n=this.cleanups.length;s<n;s++)this.cleanups[s]();if(this.cleanups.length=0,this.scopes){for(s=0,n=this.scopes.length;s<n;s++)this.scopes[s].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const i=this.parent.scopes.pop();i&&i!==this&&(this.parent.scopes[this.index]=i,i.index=this.index)}this.parent=void 0}}}function Bo(){return ve}let oe;const Fs=new WeakSet;class Si{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,ve&&(ve.active?ve.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,Fs.has(this)&&(Fs.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||Pi(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,jn(this),Ci(this);const t=oe,s=Le;oe=this,Le=!0;try{return this.fn()}finally{Ai(this),oe=t,Le=s,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)bn(t);this.deps=this.depsTail=void 0,jn(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?Fs.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Ys(this)&&this.run()}get dirty(){return Ys(this)}}let Ei=0,Kt,Wt;function Pi(e,t=!1){if(e.flags|=8,t){e.next=Wt,Wt=e;return}e.next=Kt,Kt=e}function gn(){Ei++}function mn(){if(--Ei>0)return;if(Wt){let t=Wt;for(Wt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Kt;){let t=Kt;for(Kt=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function Ci(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Ai(e){let t,s=e.depsTail,n=s;for(;n;){const i=n.prevDep;n.version===-1?(n===s&&(s=i),bn(n),Ho(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=i}e.deps=t,e.depsTail=s}function Ys(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&($i(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function $i(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Xt)||(e.globalVersion=Xt,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Ys(e))))return;e.flags|=2;const t=e.dep,s=oe,n=Le;oe=e,Le=!0;try{Ci(e);const i=e.fn(e._value);(t.version===0||Ye(i,e._value))&&(e.flags|=128,e._value=i,t.version++)}catch(i){throw t.version++,i}finally{oe=s,Le=n,Ai(e),e.flags&=-3}}function bn(e,t=!1){const{dep:s,prevSub:n,nextSub:i}=e;if(n&&(n.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let o=s.computed.deps;o;o=o.nextDep)bn(o,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function Ho(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let Le=!0;const Oi=[];function et(){Oi.push(Le),Le=!1}function tt(){const e=Oi.pop();Le=e===void 0?!0:e}function jn(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=oe;oe=void 0;try{t()}finally{oe=s}}}let Xt=0;class Vo{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class vn{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!oe||!Le||oe===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==oe)s=this.activeLink=new Vo(oe,this),oe.deps?(s.prevDep=oe.depsTail,oe.depsTail.nextDep=s,oe.depsTail=s):oe.deps=oe.depsTail=s,Di(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=oe.depsTail,s.nextDep=void 0,oe.depsTail.nextDep=s,oe.depsTail=s,oe.deps===s&&(oe.deps=n)}return s}trigger(t){this.versiLine truncated
|
||||||
|
* @vue/runtime-core v3.5.39
|
||||||
|
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||||
|
* @license MIT
|
||||||
|
**/function os(e,t,s,n){try{return n?e(...n):e()}catch(i){Rs(i,t,s)}}function Be(e,t,s,n){if(W(e)){const i=os(e,t,s,n);return i&&bi(i)&&i.catch(o=>{Rs(o,t,s)}),i}if(F(e)){const i=[];for(let o=0;o<e.length;o++)i.push(Be(e[o],t,s,n));return i}}function Rs(e,t,s,n=!0){const i=t?t.vnode:null,{errorHandler:o,throwUnhandledErrorInProduction:r}=t&&t.appContext.config||ne;if(t){let a=t.parent;const l=t.proxy,g=`https://vuejs.org/error-reference/#runtime-${s}`;for(;a;){const d=a.ec;if(d){for(let b=0;b<d.length;b++)if(d[b](e,l,g)===!1)return}a=a.parent}if(o){et(),os(o,null,10,[e,l,g]),tt();return}}gr(e,s,i,n,r)}function gr(e,t,s,n=!0,i=!1){if(i)throw e;console.error(e)}const Te=[];let qe=-1;const At=[];let ft=null,Et=0;const Li=Promise.resolve();let ys=null;function Fi(e){const t=ys||Li;return e?t.then(this?e.bind(this):e):t}function mr(e){let t=qe+1,s=Te.length;for(;t<s;){const n=t+s>>>1,i=Te[n],o=ts(i);o<e||o===e&&i.flags&2?t=n+1:s=n}return t}function xn(e){if(!(e.flags&1)){const t=ts(e),s=Te[Te.length-1];!s||!(e.flags&2)&&t>=ts(s)?Te.push(e):Te.splice(mr(t),0,e),e.flags|=1,Bi()}}function Bi(){ys||(ys=Li.then(Vi))}function br(e){F(e)?At.push(...e):ft&&e.id===-1?ft.splice(Et+1,0,e):e.flags&1||(At.push(e),e.flags|=1),Bi()}function Mn(e,t,s=qe+1){for(;s<Te.length;s++){const n=Te[s];if(n&&n.flags&2){if(e&&n.id!==e.uid)continue;Te.splice(s,1),s--,n.flags&4&&(n.flags&=-2),n(),n.flags&4||(n.flags&=-2)}}}function Hi(e){if(At.length){const t=[...new Set(At)].sort((s,n)=>ts(s)-ts(n));if(At.length=0,ft){ft.push(...t);return}for(ft=t,Et=0;Et<ft.length;Et++){const s=ft[Et];s.flags&4&&(s.flags&=-2),s.flags&8||s(),s.flags&=-2}ft=null,Et=0}}const ts=e=>e.id==null?e.flags&2?-1:1/0:e.id;function Vi(e){try{for(qe=0;qe<Te.length;qe++){const t=Te[qe];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),os(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;qe<Te.length;qe++){const t=Te[qe];t&&(t.flags&=-2)}qe=-1,Te.length=0,Hi(),ys=null,(Te.length||At.length)&&Vi()}}let je=null,Gi=null;function _s(e){const t=je;return je=e,Gi=e&&e.type.__scopeId||null,t}function vr(e,t=je,s){if(!t||e._n)return e;const n=(...i)=>{n._d&&Zn(-1);const o=_s(t);let r;try{r=e(...i)}finally{_s(o),n._d&&Zn(1)}return r};return n._n=!0,n._c=!0,n._d=!0,n}function fe(e,t){if(je===null)return e;const s=Us(je),n=e.dirs||(e.dirs=[]);for(let i=0;i<t.length;i++){let[o,r,a,l=ne]=t[i];o&&(W(o)&&(o={mounted:o,updated:o}),o.deep&<(r),n.push({dir:o,instance:s,value:r,oldValue:void 0,arg:a,modifiers:l}))}return e}function _t(e,t,s,n){const i=e.dirs,o=t&&t.dirs;for(let r=0;r<i.length;r++){const a=i[r];o&&(a.oldValue=o[r].value);let l=a.dir[n];l&&(et(),Be(l,s,8,[e.el,a,e,t]),tt())}}function yr(e,t){if(Se){let s=Se.provides;const n=Se.parent&&Se.parent.provides;n===s&&(s=Se.provides=Object.create(n)),s[e]=t}}function gs(e,t,s=!1){const n=ma();if(n||$t){let i=$t?$t._context.provides:n?n.parent==null||n.ce?n.vnode.appContext&&n.vnode.appContext.provides:n.parent.provides:void 0;if(i&&e in i)return i[e];if(arguments.length>1)return s&&W(t)?t.call(n&&n.proxy):t}}const _r=Symbol.for("v-scx"),wr=()=>gs(_r);function Vs(e,t,s){return Ki(e,t,s)}function Ki(e,t,s=ne){const{immediate:n,deep:i,flush:o,once:r}=s,a=xe({},s),l=t&&n||!t&&o!=="post";let g;if(ns){if(o==="sync"){const C=wr();g=C.__watcherHandles||(C.__watcherHandles=[])}else if(!l){const C=()=>{};return C.stop=Xe,C.resume=Xe,C.pause=Xe,C}}const d=Se;a.call=(C,G,k)=>Be(C,d,G,k);let b=!1;o==="post"?a.scheduler=C=>{Ce(C,d&&d.suspense)}:o!=="sync"&&(b=!0,a.scheduler=(C,G)=>{G?C():xn(C)}),a.augmentJob=C=>{t&&(C.flags|=4),b&&(C.flags|=2,d&&(C.id=d.uid,C.i=d))};const E=hr(e,t,a);return ns&&(g?g.push(E):l&&E()),E}function xr(e,t,s){const n=this.proxy,i=de(e)?e.includes(".")?Wi(n,e):()=>n[e]:e.bind(n,n);let o;W(t)?o=t:(o=t.handler,s=t);const r=rs(this),a=Ki(i,o.bind(n),s);return r(),a}function Wi(e,t){const s=t.split(".");return()=>{let n=e;for(let i=0;i<s.length&&n;i++)n=n[s[i]];return n}}const kr=Symbol("_vte"),Tr=e=>e.__isTeleport,Gs=Symbol("_leaveCb");function kn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,kn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function qi(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function Nn(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const ws=new WeakMap;function qt(e,t,s,n,i=!1){if(F(e)){e.forEach((k,M)=>qt(k,t&&(F(t)?t[M]:t),s,n,i));return}if(Zt(n)&&!i){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&qt(e,t,s,n.component.subTree);return}const o=n.shapeFlag&4?Us(n.component):n.el,r=i?null:o,{i:a,r:l}=e,g=t&&t.r,d=a.refs===ne?a.refs={}:a.refs,b=a.setupState,E=Y(b),C=b===ne?mi:k=>Nn(d,k)?!1:X(E,k),G=(k,M)=>!(M&&Nn(d,M));if(g!=null&&g!==l){if(Ln(t),de(g))d[g]=null,C(g)&&(b[g]=null);else if(we(g)){const k=t;G(g,k.k)&&(g.value=null),k.k&&(d[k.k]=null)}}if(W(l)){et();try{os(l,a,12,[r,d])}finally{tt()}}else{const k=de(l),M=we(l);if(k||M){const z=()=>{if(e.f
Line truncated
|
||||||
|
* @vue/runtime-dom v3.5.39
|
||||||
|
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||||
|
* @license MIT
|
||||||
|
**/let ln;const Xn=typeof window<"u"&&window.trustedTypes;if(Xn)try{ln=Xn.createPolicy("vue",{createHTML:e=>e})}catch{}const _o=ln?e=>ln.createHTML(e):e=>e,ka="http://www.w3.org/2000/svg",Ta="http://www.w3.org/1998/Math/MathML",ot=typeof document<"u"?document:null,Qn=ot&&ot.createElement("template"),Sa={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const i=t==="svg"?ot.createElementNS(ka,e):t==="mathml"?ot.createElementNS(Ta,e):s?ot.createElement(e,{is:s}):ot.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>ot.createTextNode(e),createComment:e=>ot.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>ot.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,i,o){const r=s?s.previousSibling:t.lastChild;if(i&&(i===o||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),s),!(i===o||!(i=i.nextSibling)););else{Qn.innerHTML=_o(n==="svg"?`<svg>${e}</svg>`:n==="mathml"?`<math>${e}</math>`:e);const a=Qn.content;if(n==="svg"||n==="mathml"){const l=a.firstChild;for(;l.firstChild;)a.appendChild(l.firstChild);a.removeChild(l)}t.insertBefore(a,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},Ea=Symbol("_vtc");function Pa(e,t,s){const n=e[Ea];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const ei=Symbol("_vod"),Ca=Symbol("_vsh"),Aa=Symbol(""),$a=/(?:^|;)\s*display\s*:/;function Oa(e,t,s){const n=e.style,i=de(s);let o=!1;if(s&&!i){if(t)if(de(t))for(const r of t.split(";")){const a=r.slice(0,r.indexOf(":")).trim();s[a]==null&&Vt(n,a,"")}else for(const r in t)s[r]==null&&Vt(n,r,"");for(const r in s){r==="display"&&(o=!0);const a=s[r];a!=null?Ra(e,r,!de(t)&&t?t[r]:void 0,a)||Vt(n,r,a):Vt(n,r,"")}}else if(i){if(t!==s){const r=n[Aa];r&&(s+=";"+r),n.cssText=s,o=$a.test(s)}}else t&&e.removeAttribute("style");ei in e&&(e[ei]=o?n.display:"",e[Ca]&&(n.display="none"))}const ti=/\s*!important$/;function Vt(e,t,s){if(F(s))s.forEach(n=>Vt(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=Da(e,t);ti.test(s)?e.setProperty(bt(n),s.replace(ti,""),"important"):e[n]=s}}const si=["Webkit","Moz","ms"],Zs={};function Da(e,t){const s=Zs[t];if(s)return s;let n=Ne(t);if(n!=="filter"&&n in e)return Zs[t]=n;n=_i(n);for(let i=0;i<si.length;i++){const o=si[i]+n;if(o in e)return Zs[t]=o}return t}function Ra(e,t,s,n){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&de(n)&&s===n}const ni="http://www.w3.org/1999/xlink";function ii(e,t,s,n,i,o=No(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(ni,t.slice(6,t.length)):e.setAttributeNS(ni,t,s):s==null||o&&!xi(s)?e.removeAttribute(t):e.setAttribute(t,o?"":Qe(s)?String(s):s)}function oi(e,t,s,n,i){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?_o(s):s);return}const o=e.tagName;if(t==="value"&&o!=="PROGRESS"&&!o.includes("-")){const a=o==="OPTION"?e.getAttribute("value")||"":e.value,l=s==null?e.type==="checkbox"?"on":"":String(s);(a!==l||!("_value"in e))&&(e.value=l),s==null&&e.removeAttribute(t),e._value=s;return}let r=!1;if(s===""||s==null){const a=typeof e[t];a==="boolean"?s=xi(s):s==null&&a==="string"?(s="",r=!0):a==="number"&&(s=0,r=!0)}try{e[t]=s}catch{}r&&e.removeAttribute(i||t)}function ct(e,t,s,n){e.addEventListener(t,s,n)}function za(e,t,s,n){e.removeEventListener(t,s,n)}const ri=Symbol("_vei");function Ia(e,t,s,n,i=null){const o=e[ri]||(e[ri]={}),r=o[t];if(n&&r)r.value=n;else{const[a,l]=Ma(t);if(n){const g=o[t]=Fa(n,i);ct(e,a,g,l)}else r&&(za(e,a,r,l),o[t]=void 0)}}const ja=/(Once|Passive|Capture)$/,Ua=/^on:?(?:Once|Passive|Capture)$/;function Ma(e){let t,s;for(;(s=e.match(ja))&&!Ua.test(e);)t||(t={}),e=e.slice(0,e.length-s[1].length),t[s[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):bt(e.slice(2)),t]}let Js=0;const Na=Promise.resolve(),La=()=>Js||(Na.then(()=>Js=0),Js=Date.now());function Fa(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const i=s.value;if(F(i)){const o=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{o.call(n),n._stopped=!0};const r=i.slice(),a=[n];for(let l=0;l<r.length&&!n._stopped;l++){const g=r[l];g&&Be(g,t,5,a)}}else Be(i,t,5,[n])};return s.value=e,s.attached=La(),s}const ai=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Ba=(e,t,s,n,i,o)=>{const r=i==="svg";t==="class"?Pa(e,n,r):t==="style"?Oa(e,s,n):Ps(t)?Cs(t)||Ia(e,t,s,n,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Ha(e,t,n,r))?(oi(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&ii(e,t,n,r,o,t!=="value")):e._isVueCE&&(Va(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!de(n)))?oi(e,Ne(t),n,o,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._fals
Line truncated
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
@import"https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;0,700;0,800;1,700;1,800&family=DM+Mono:ital,wght@0,400;0,500&display=swap";/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--shadow-xs:var(--shadow-xs);--shadow-sm:var(--shadow-sm);--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius-control:12px;--radius-card:18px;--radius-pill:999px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:nLine truncated
|
@import"https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;0,700;0,800;1,700;1,800&family=DM+Mono:ital,wght@0,400;0,500&display=swap";/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--shadow-xs:var(--shadow-xs);--shadow-sm:var(--shadow-sm);--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius-control:12px;--radius-card:18px;--radius-pill:999px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:nLine truncated
|
||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#2563eb" />
|
<meta name="theme-color" content="#2563eb" />
|
||||||
<title>DriverVault · API Server</title>
|
<title>DriverVault · API Server</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BBZfstAT.js"></script>
|
<script type="module" crossorigin src="/assets/index-CUgU2Fxs.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-_8IdQ1wh.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Cd85xDqG.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -62,6 +62,52 @@ func normalizeControlMode(v string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ankerControlModesAll lists every control mode, in the order the clients offer
|
||||||
|
// them. off leads because it is the default and the fallback.
|
||||||
|
var ankerControlModesAll = []string{ankerControlOff, ankerControlMqtt, ankerControlModbus, ankerControlOwn, ankerControlProxy}
|
||||||
|
|
||||||
|
// parseControlModes reads a stored comma-separated mode list into a set,
|
||||||
|
// dropping anything unrecognized. off is never in it: monitoring-only is what a
|
||||||
|
// charger falls back to, so no layer may take it away from the layers below.
|
||||||
|
func parseControlModes(v string) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, part := range strings.Split(v, ",") {
|
||||||
|
if m := normalizeControlMode(part); m != "" && m != ankerControlOff {
|
||||||
|
out[m] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinControlModes renders a mode set back to its stored form, in the canonical
|
||||||
|
// order, so what a client sends round-trips to a predictable string.
|
||||||
|
func joinControlModes(set map[string]bool) string {
|
||||||
|
return strings.Join(controlModeList(set), ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// controlModeList sorts a mode set into the canonical order.
|
||||||
|
func controlModeList(set map[string]bool) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, m := range ankerControlModesAll {
|
||||||
|
if set[m] {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// controlModesOffered lists the modes a layer may still choose once the layers
|
||||||
|
// above it have hidden theirs.
|
||||||
|
func controlModesOffered(hidden map[string]bool) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, m := range ankerControlModesAll {
|
||||||
|
if !hidden[m] {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ankerConfig is one layer's Anker Solix settings.
|
// ankerConfig is one layer's Anker Solix settings.
|
||||||
type ankerConfig struct {
|
type ankerConfig struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -70,6 +116,12 @@ type ankerConfig struct {
|
|||||||
// ControlMode is the control path: off | mqtt | modbus | own | proxy.
|
// ControlMode is the control path: off | mqtt | modbus | own | proxy.
|
||||||
// It resolves independently of the credentials, like Country.
|
// It resolves independently of the credentials, like Country.
|
||||||
ControlMode string `json:"controlMode"`
|
ControlMode string `json:"controlMode"`
|
||||||
|
// ControlModesDisabled is a comma-separated list of modes this layer hides
|
||||||
|
// from the layers below it — a mode a deployment (or an organization) does
|
||||||
|
// not want offered at all. It does not constrain this layer's own choice, and
|
||||||
|
// it never hides off. Meaningful on the global and organization layers only:
|
||||||
|
// a user has nobody below them.
|
||||||
|
ControlModesDisabled string `json:"controlModesDisabled,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ankerChargerBinding is what we know about one charger the caller controls:
|
// ankerChargerBinding is what we know about one charger the caller controls:
|
||||||
@@ -128,6 +180,14 @@ type ankerResolution struct {
|
|||||||
available bool // global master switch (plugin enabled in the panel)
|
available bool // global master switch (plugin enabled in the panel)
|
||||||
orgEnabled bool // org gate (default true; gates the org's users)
|
orgEnabled bool // org gate (default true; gates the org's users)
|
||||||
enabled bool // caller's personal enable flag
|
enabled bool // caller's personal enable flag
|
||||||
|
|
||||||
|
// hiddenForOrg is what the global layer hides from everyone below it;
|
||||||
|
// hiddenForUser adds what the caller's organization hides from its own users.
|
||||||
|
// They gate both the pickers the clients draw and which layer's stored mode
|
||||||
|
// is allowed to take effect.
|
||||||
|
hiddenForOrg map[string]bool
|
||||||
|
hiddenForUser map[string]bool
|
||||||
|
orgHidden map[string]bool // the organization layer's own list, for its editor
|
||||||
}
|
}
|
||||||
|
|
||||||
// ankerLayerRank orders the cascade layers; a higher number is lower priority.
|
// ankerLayerRank orders the cascade layers; a higher number is lower priority.
|
||||||
@@ -137,7 +197,8 @@ var ankerLayerRank = map[string]int{"global": 1, "org": 2, "user": 3}
|
|||||||
// pluginSettings blob (read from their user record).
|
// pluginSettings blob (read from their user record).
|
||||||
func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) ankerResolution {
|
func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) ankerResolution {
|
||||||
g, masterEnabled, _ := s.plugins.RawConfig(ankerPlugin)
|
g, masterEnabled, _ := s.plugins.RawConfig(ankerPlugin)
|
||||||
gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"], ControlMode: g["controlMode"]}
|
gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"],
|
||||||
|
ControlMode: g["controlMode"], ControlModesDisabled: g["controlModesDisabled"]}
|
||||||
|
|
||||||
var oStored ankerStored
|
var oStored ankerStored
|
||||||
if who.OrgID != "" {
|
if who.OrgID != "" {
|
||||||
@@ -166,6 +227,21 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
|
|||||||
enabled: uStored.Enabled,
|
enabled: uStored.Enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What each layer below may still be offered. The global list reaches
|
||||||
|
// everyone; an organization's own list reaches only its users, so it narrows
|
||||||
|
// the set once more on the way down.
|
||||||
|
res.hiddenForOrg = parseControlModes(gc.ControlModesDisabled)
|
||||||
|
res.orgHidden = parseControlModes(oc.ControlModesDisabled)
|
||||||
|
res.hiddenForUser = map[string]bool{}
|
||||||
|
for m := range res.hiddenForOrg {
|
||||||
|
res.hiddenForUser[m] = true
|
||||||
|
}
|
||||||
|
if who.OrgID != "" {
|
||||||
|
for m := range res.orgHidden {
|
||||||
|
res.hiddenForUser[m] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ordered layers, top (highest priority) first.
|
// Ordered layers, top (highest priority) first.
|
||||||
type layer struct {
|
type layer struct {
|
||||||
name string
|
name string
|
||||||
@@ -189,13 +265,18 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
|
|||||||
// Control mode resolves independently too, defaulting to off (monitoring
|
// Control mode resolves independently too, defaulting to off (monitoring
|
||||||
// only). The highest layer that sets a recognized value wins; an explicit
|
// only). The highest layer that sets a recognized value wins; an explicit
|
||||||
// "off" set above still wins (and locks lower layers to monitoring).
|
// "off" set above still wins (and locks lower layers to monitoring).
|
||||||
|
// A mode hidden by a layer above is not merely absent from the picker: a value
|
||||||
|
// stored below before it was hidden stops taking effect too, so switching a
|
||||||
|
// mode off really does switch it off everywhere underneath.
|
||||||
res.eff.ControlMode = ankerControlOff
|
res.eff.ControlMode = ankerControlOff
|
||||||
res.source["controlMode"] = "unset"
|
res.source["controlMode"] = "unset"
|
||||||
for _, l := range layers {
|
for _, l := range layers {
|
||||||
if v := normalizeControlMode(l.c.ControlMode); v != "" {
|
v := normalizeControlMode(l.c.ControlMode)
|
||||||
res.eff.ControlMode, res.source["controlMode"] = v, l.name
|
if v == "" || res.hiddenAt(l.name)[v] {
|
||||||
break
|
continue
|
||||||
}
|
}
|
||||||
|
res.eff.ControlMode, res.source["controlMode"] = v, l.name
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Credentials resolve as a pair from the highest layer with an email, so the
|
// Credentials resolve as a pair from the highest layer with an email, so the
|
||||||
@@ -212,6 +293,19 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hiddenAt is the set of modes hidden from one cascade layer by the layers above
|
||||||
|
// it. The global layer answers to nobody, so nothing is hidden from it.
|
||||||
|
func (r ankerResolution) hiddenAt(layer string) map[string]bool {
|
||||||
|
switch layer {
|
||||||
|
case "org":
|
||||||
|
return r.hiddenForOrg
|
||||||
|
case "user":
|
||||||
|
return r.hiddenForUser
|
||||||
|
default:
|
||||||
|
return map[string]bool{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ankerLockedFor reports whether a field whose value comes from source is locked
|
// ankerLockedFor reports whether a field whose value comes from source is locked
|
||||||
// for a caller whose editable layer is editable (i.e. the value is set above them).
|
// for a caller whose editable layer is editable (i.e. the value is set above them).
|
||||||
func ankerLockedFor(source, editable string) bool {
|
func ankerLockedFor(source, editable string) bool {
|
||||||
@@ -301,7 +395,13 @@ func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string
|
|||||||
}
|
}
|
||||||
return fv
|
return fv
|
||||||
}
|
}
|
||||||
return map[string]any{
|
// The modes this scope may pick from: everything the layers above it left
|
||||||
|
// standing. A superadmin's read-only view answers as the user layer they are.
|
||||||
|
hidden := res.hiddenForUser
|
||||||
|
if editable == "org" {
|
||||||
|
hidden = res.hiddenForOrg
|
||||||
|
}
|
||||||
|
out := map[string]any{
|
||||||
"editableLayer": editable,
|
"editableLayer": editable,
|
||||||
"fields": map[string]ankerFieldView{
|
"fields": map[string]ankerFieldView{
|
||||||
"email": field("email", res.eff.Email, own.Email, false),
|
"email": field("email", res.eff.Email, own.Email, false),
|
||||||
@@ -309,7 +409,15 @@ func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string
|
|||||||
"country": field("country", res.eff.Country, own.Country, false),
|
"country": field("country", res.eff.Country, own.Country, false),
|
||||||
"controlMode": field("controlMode", res.eff.ControlMode, own.ControlMode, false),
|
"controlMode": field("controlMode", res.eff.ControlMode, own.ControlMode, false),
|
||||||
},
|
},
|
||||||
|
"controlModes": controlModesOffered(hidden),
|
||||||
}
|
}
|
||||||
|
if editable == "org" {
|
||||||
|
// The organization's own hide-list, which its admin edits here. Modes the
|
||||||
|
// global layer already hid are not in controlModes above, so they simply
|
||||||
|
// never come up.
|
||||||
|
out["controlModesDisabled"] = controlModeList(res.orgHidden)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ankerView builds the masked, client-safe response body from a resolution.
|
// ankerView builds the masked, client-safe response body from a resolution.
|
||||||
@@ -319,6 +427,7 @@ func (s *Server) ankerView(who *callerIdentity, res ankerResolution) map[string]
|
|||||||
"orgEnabled": res.orgEnabled,
|
"orgEnabled": res.orgEnabled,
|
||||||
"enabled": res.enabled,
|
"enabled": res.enabled,
|
||||||
"controlMode": res.eff.ControlMode, // effective control mode (off|mqtt|modbus|own|proxy)
|
"controlMode": res.eff.ControlMode, // effective control mode (off|mqtt|modbus|own|proxy)
|
||||||
|
"controlModes": controlModesOffered(res.hiddenForUser), // modes still offered to this caller
|
||||||
"role": who.Role,
|
"role": who.Role,
|
||||||
"orgId": who.OrgID,
|
"orgId": who.OrgID,
|
||||||
"canEditOrg": res.canOrg,
|
"canEditOrg": res.canOrg,
|
||||||
@@ -430,7 +539,21 @@ func (s *Server) handlePutAnker(w http.ResponseWriter, r *http.Request) {
|
|||||||
applyField("email", func(c *ankerConfig, v string) { c.Email = v })
|
applyField("email", func(c *ankerConfig, v string) { c.Email = v })
|
||||||
applyField("password", func(c *ankerConfig, v string) { c.Password = v })
|
applyField("password", func(c *ankerConfig, v string) { c.Password = v })
|
||||||
applyField("country", func(c *ankerConfig, v string) { c.Country = strings.ToUpper(v) })
|
applyField("country", func(c *ankerConfig, v string) { c.Country = strings.ToUpper(v) })
|
||||||
applyField("controlMode", func(c *ankerConfig, v string) { c.ControlMode = normalizeControlMode(v) })
|
applyField("controlMode", func(c *ankerConfig, v string) {
|
||||||
|
m := normalizeControlMode(v)
|
||||||
|
// A mode hidden above is not a choice this layer can make; keep whatever it
|
||||||
|
// had rather than storing something that would never take effect.
|
||||||
|
if m != "" && res.hiddenAt(editable)[m] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.ControlMode = m
|
||||||
|
})
|
||||||
|
if editable == "org" {
|
||||||
|
// Only a layer with users under it has anything to hide from them.
|
||||||
|
applyField("controlModesDisabled", func(c *ankerConfig, v string) {
|
||||||
|
c.ControlModesDisabled = joinControlModes(parseControlModes(v))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Persist the organization layer (admins) via the service account.
|
// Persist the organization layer (admins) via the service account.
|
||||||
if editable == "org" {
|
if editable == "org" {
|
||||||
|
|||||||
@@ -241,3 +241,83 @@ func TestAnkerCardRoutesAreRegistered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseControlModes(t *testing.T) {
|
||||||
|
got := parseControlModes(" Proxy , own ,bogus,, off ")
|
||||||
|
// off is never hideable: monitoring only is what a charger falls back to.
|
||||||
|
want := map[string]bool{"proxy": true, "own": true}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("parseControlModes = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for m := range want {
|
||||||
|
if !got[m] {
|
||||||
|
t.Errorf("parseControlModes is missing %q", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := joinControlModes(got); s != "own,proxy" {
|
||||||
|
t.Errorf("joinControlModes = %q, want own,proxy (canonical order)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlModesOffered(t *testing.T) {
|
||||||
|
got := controlModesOffered(map[string]bool{"own": true, "proxy": true})
|
||||||
|
want := []string{"off", "mqtt", "modbus"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("controlModesOffered = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i, m := range want {
|
||||||
|
if got[i] != m {
|
||||||
|
t.Errorf("controlModesOffered[%d] = %q, want %q", i, got[i], m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mode the global layer hides is not merely absent from a lower picker: a
|
||||||
|
// value stored below before it was hidden stops taking effect too.
|
||||||
|
func TestHiddenControlModeDoesNotTakeEffect(t *testing.T) {
|
||||||
|
res := ankerResolution{
|
||||||
|
hiddenForOrg: map[string]bool{"own": true, "proxy": true},
|
||||||
|
hiddenForUser: map[string]bool{"own": true, "proxy": true},
|
||||||
|
}
|
||||||
|
if !res.hiddenAt("user")["proxy"] {
|
||||||
|
t.Error("proxy should be hidden from the user layer")
|
||||||
|
}
|
||||||
|
if res.hiddenAt("global")["proxy"] {
|
||||||
|
t.Error("nothing is hidden from the global layer — it is the one hiding")
|
||||||
|
}
|
||||||
|
view := (&Server{}).ankerScopeView(res, "user")
|
||||||
|
for _, m := range view["controlModes"].([]string) {
|
||||||
|
if m == "own" || m == "proxy" {
|
||||||
|
t.Errorf("hidden mode %q is still offered to the user scope", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := view["controlModesDisabled"]; ok {
|
||||||
|
t.Error("the user scope has nobody below it and must carry no hide-list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An organization narrows the set once more for its own users, and its admin
|
||||||
|
// edits that list in the org scope.
|
||||||
|
func TestOrgScopeCarriesItsOwnHideList(t *testing.T) {
|
||||||
|
res := ankerResolution{
|
||||||
|
hiddenForOrg: map[string]bool{"proxy": true},
|
||||||
|
hiddenForUser: map[string]bool{"proxy": true, "own": true},
|
||||||
|
orgHidden: map[string]bool{"own": true},
|
||||||
|
}
|
||||||
|
org := (&Server{}).ankerScopeView(res, "org")
|
||||||
|
offered := org["controlModes"].([]string)
|
||||||
|
var sawOwn, sawProxy bool
|
||||||
|
for _, m := range offered {
|
||||||
|
sawOwn = sawOwn || m == "own"
|
||||||
|
sawProxy = sawProxy || m == "proxy"
|
||||||
|
}
|
||||||
|
if !sawOwn {
|
||||||
|
t.Error("an org may still choose a mode it only hides from its users")
|
||||||
|
}
|
||||||
|
if sawProxy {
|
||||||
|
t.Error("a mode the global layer hid must not reach the org picker")
|
||||||
|
}
|
||||||
|
if got := org["controlModesDisabled"].([]string); len(got) != 1 || got[0] != "own" {
|
||||||
|
t.Errorf("org hide-list = %v, want [own]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,24 @@ type Options struct {
|
|||||||
SuperAdminEmail string
|
SuperAdminEmail string
|
||||||
SuperAdminPassword string
|
SuperAdminPassword string
|
||||||
SuperAdminName string
|
SuperAdminName string
|
||||||
|
|
||||||
|
// S3, when non-nil and Enabled, points PocketBase's record-file storage at
|
||||||
|
// a bucket. Nil is the normal case — a stack started without one of the
|
||||||
|
// SeaweedFS compose overlays keeps its uploads on the pb_data volume.
|
||||||
|
S3 *S3Options
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3Options describes the bucket PocketBase should keep record files in. It
|
||||||
|
// mirrors PocketBase's own settings block one field at a time, so there is
|
||||||
|
// nothing to translate at the wire.
|
||||||
|
type S3Options struct {
|
||||||
|
Enabled bool
|
||||||
|
Bucket string
|
||||||
|
Region string
|
||||||
|
Endpoint string
|
||||||
|
AccessKey string
|
||||||
|
Secret string
|
||||||
|
ForcePathStyle bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// fieldDef is a schema field normalized to a single shape; it is rendered into
|
// fieldDef is a schema field normalized to a single shape; it is rendered into
|
||||||
@@ -190,6 +208,10 @@ func Run(ctx context.Context, client *pb.Client, opts Options) error {
|
|||||||
if err := ensureSuperAdmin(ctx, client, opts); err != nil {
|
if err := ensureSuperAdmin(ctx, client, opts); err != nil {
|
||||||
return fmt.Errorf("super-admin: %w", err)
|
return fmt.Errorf("super-admin: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureFileStorage(ctx, client, opts.S3); err != nil {
|
||||||
|
return fmt.Errorf("file storage: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +455,102 @@ func ensureSuperAdmin(ctx context.Context, client *pb.Client, opts Options) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- file storage ----------------------------------------------------------
|
||||||
|
|
||||||
|
// storageSettings is the slice of PocketBase's settings this step owns. The
|
||||||
|
// json names are PocketBase's own (core.S3Config), so the PATCH body is just
|
||||||
|
// this type marshalled back out.
|
||||||
|
type storageSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
AccessKey string `json:"accessKey"`
|
||||||
|
Secret string `json:"secret,omitempty"`
|
||||||
|
ForcePathStyle bool `json:"forcePathStyle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameExceptSecret compares everything a read of the settings can be trusted
|
||||||
|
// on. PocketBase masks the stored secret, so a rotation of the secret alone is
|
||||||
|
// invisible from here — changing any other PB_S3_* value forces the write, and
|
||||||
|
// so does editing it in the admin UI.
|
||||||
|
func (s storageSettings) sameExceptSecret(other storageSettings) bool {
|
||||||
|
s.Secret, other.Secret = "", ""
|
||||||
|
return s == other
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureFileStorage points PocketBase's record-file storage at the configured
|
||||||
|
// bucket, and does nothing at all when no bucket was asked for.
|
||||||
|
//
|
||||||
|
// It never turns S3 *off*: files already written to a bucket are only reachable
|
||||||
|
// while PocketBase is still pointed at it, so dropping the overlay leaves the
|
||||||
|
// setting where it is rather than stranding every existing attachment. Moving
|
||||||
|
// back to local storage is a deliberate act in the admin UI.
|
||||||
|
func ensureFileStorage(ctx context.Context, client *pb.Client, opts *S3Options) error {
|
||||||
|
if opts == nil || !opts.Enabled {
|
||||||
|
return nil // not requested
|
||||||
|
}
|
||||||
|
want := storageSettings{
|
||||||
|
Enabled: true,
|
||||||
|
Bucket: opts.Bucket,
|
||||||
|
Region: opts.Region,
|
||||||
|
Endpoint: opts.Endpoint,
|
||||||
|
AccessKey: opts.AccessKey,
|
||||||
|
Secret: opts.Secret,
|
||||||
|
ForcePathStyle: opts.ForcePathStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, status, err := client.Raw(ctx, http.MethodGet, "/api/settings", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return fmt.Errorf("read settings: status %d: %s", status, raw)
|
||||||
|
}
|
||||||
|
var current struct {
|
||||||
|
S3 storageSettings `json:"s3"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, ¤t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.S3.sameExceptSecret(want) {
|
||||||
|
log.Printf("bootstrap: • file storage already on S3 (%s)", want.Bucket)
|
||||||
|
} else {
|
||||||
|
// Only the s3 block is sent: everything else in the settings — mail,
|
||||||
|
// backups, rate limits — belongs to whoever set it.
|
||||||
|
raw, status, err = client.Raw(ctx, http.MethodPatch, "/api/settings",
|
||||||
|
map[string]any{"s3": want})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return fmt.Errorf("apply settings: status %d: %s", status, raw)
|
||||||
|
}
|
||||||
|
log.Printf("bootstrap: ✓ file storage → S3 (%s at %s)", want.Bucket, want.Endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFileStorage(ctx, client)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// testFileStorage asks PocketBase to prove it can actually reach the bucket,
|
||||||
|
// and only says so in the log. A failure here means uploads will fail, but the
|
||||||
|
// server still has to come up — the endpoint is fixable from the panel, and a
|
||||||
|
// stack that refuses to boot cannot be fixed from anywhere.
|
||||||
|
func testFileStorage(ctx context.Context, client *pb.Client) {
|
||||||
|
raw, status, err := client.Raw(ctx, http.MethodPost, "/api/settings/test/s3",
|
||||||
|
map[string]any{"filesystem": "storage"})
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
log.Printf("bootstrap: WARNING: S3 storage test failed: %v", err)
|
||||||
|
case status < 200 || status >= 300:
|
||||||
|
log.Printf("bootstrap: WARNING: S3 storage unreachable (status %d): %s", status, raw)
|
||||||
|
default:
|
||||||
|
log.Printf("bootstrap: ✓ S3 storage reachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- helpers ---------------------------------------------------------------
|
// --- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
func asBool(v any) bool {
|
func asBool(v any) bool {
|
||||||
|
|||||||
@@ -94,3 +94,39 @@ func TestSchemaConsistency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSameExceptSecret covers the decision ensureFileStorage makes on every
|
||||||
|
// boot: write the settings, or leave them alone. The secret is excluded because
|
||||||
|
// PocketBase masks it on read — comparing it would make every boot a write.
|
||||||
|
func TestSameExceptSecret(t *testing.T) {
|
||||||
|
want := storageSettings{
|
||||||
|
Enabled: true,
|
||||||
|
Bucket: "drivervault",
|
||||||
|
Region: "us-east-1",
|
||||||
|
Endpoint: "http://seaweedfs:8333",
|
||||||
|
AccessKey: "key",
|
||||||
|
Secret: "secret",
|
||||||
|
ForcePathStyle: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
current storageSettings
|
||||||
|
same bool
|
||||||
|
}{
|
||||||
|
{"identical", want, true},
|
||||||
|
{"masked secret", func() storageSettings { s := want; s.Secret = ""; return s }(), true},
|
||||||
|
{"rotated secret only", func() storageSettings { s := want; s.Secret = "other"; return s }(), true},
|
||||||
|
{"changed endpoint", func() storageSettings { s := want; s.Endpoint = "http://elsewhere:8333"; return s }(), false},
|
||||||
|
{"changed bucket", func() storageSettings { s := want; s.Bucket = "other"; return s }(), false},
|
||||||
|
{"changed access key", func() storageSettings { s := want; s.AccessKey = "other"; return s }(), false},
|
||||||
|
{"still disabled", func() storageSettings { s := want; s.Enabled = false; return s }(), false},
|
||||||
|
{"path style off", func() storageSettings { s := want; s.ForcePathStyle = false; return s }(), false},
|
||||||
|
{"untouched settings", storageSettings{}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := tc.current.sameExceptSecret(want); got != tc.same {
|
||||||
|
t.Errorf("%s: sameExceptSecret = %v, want %v", tc.name, got, tc.same)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,26 @@ type Config struct {
|
|||||||
SuperAdminEmail string
|
SuperAdminEmail string
|
||||||
SuperAdminPassword string
|
SuperAdminPassword string
|
||||||
SuperAdminName string
|
SuperAdminName string
|
||||||
|
|
||||||
|
// PocketBase file storage. With S3Enabled set, bootstrap points PocketBase's
|
||||||
|
// "Files storage" at this bucket instead of the pb_data volume; left unset,
|
||||||
|
// uploads stay on disk exactly as they always have. Only *record files* move
|
||||||
|
// — backups are deliberately not touched.
|
||||||
|
//
|
||||||
|
// Nothing here reaches a container unless one of the SeaweedFS compose
|
||||||
|
// overlays is layered on, so an existing stack is unaffected by an upgrade.
|
||||||
|
// S3Endpoint has no default: an in-stack SeaweedFS and one outside it are
|
||||||
|
// different addresses, and guessing either would be worse than not starting.
|
||||||
|
S3Enabled bool
|
||||||
|
S3Bucket string
|
||||||
|
S3Region string
|
||||||
|
S3Endpoint string
|
||||||
|
S3AccessKey string
|
||||||
|
S3Secret string
|
||||||
|
// S3ForcePathStyle keeps bucket names in the path rather than the hostname.
|
||||||
|
// True by default because that is what a self-hosted gateway serves —
|
||||||
|
// virtual-host style would need a DNS entry per bucket.
|
||||||
|
S3ForcePathStyle bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnvFile is the .env path (relative to the working directory) that Load reads
|
// EnvFile is the .env path (relative to the working directory) that Load reads
|
||||||
@@ -66,6 +86,15 @@ func (c Config) AdminConfigured() bool {
|
|||||||
return c.PocketBaseAdminEmail != "" && c.PocketBaseAdminPassword != ""
|
return c.PocketBaseAdminEmail != "" && c.PocketBaseAdminPassword != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StorageConfigured reports whether S3 file storage has been fully specified.
|
||||||
|
// Anything less than all of it counts as "not asked for": a half-filled .env
|
||||||
|
// leaves uploads on the local volume rather than pointing PocketBase at a
|
||||||
|
// bucket it has no way to reach.
|
||||||
|
func (c Config) StorageConfigured() bool {
|
||||||
|
return c.S3Enabled && c.S3Bucket != "" && c.S3Endpoint != "" &&
|
||||||
|
c.S3AccessKey != "" && c.S3Secret != ""
|
||||||
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables, applying sensible
|
// Load reads configuration from environment variables, applying sensible
|
||||||
// defaults. A .env file, if present in the working directory, is loaded first.
|
// defaults. A .env file, if present in the working directory, is loaded first.
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
@@ -86,6 +115,13 @@ func Load() Config {
|
|||||||
SuperAdminEmail: firstEnv("DRIVERVAULT_SUPERADMIN_EMAIL", "SUPERADMIN_EMAIL"),
|
SuperAdminEmail: firstEnv("DRIVERVAULT_SUPERADMIN_EMAIL", "SUPERADMIN_EMAIL"),
|
||||||
SuperAdminPassword: firstEnv("DRIVERVAULT_SUPERADMIN_PASSWORD", "SUPERADMIN_PASSWORD"),
|
SuperAdminPassword: firstEnv("DRIVERVAULT_SUPERADMIN_PASSWORD", "SUPERADMIN_PASSWORD"),
|
||||||
SuperAdminName: getenv("DRIVERVAULT_SUPERADMIN_NAME", "Administrator"),
|
SuperAdminName: getenv("DRIVERVAULT_SUPERADMIN_NAME", "Administrator"),
|
||||||
|
S3Enabled: boolEnv("PB_S3_ENABLED", false),
|
||||||
|
S3Bucket: getenv("PB_S3_BUCKET", "drivervault"),
|
||||||
|
S3Region: getenv("PB_S3_REGION", "us-east-1"),
|
||||||
|
S3Endpoint: strings.TrimRight(getenv("PB_S3_ENDPOINT", ""), "/"),
|
||||||
|
S3AccessKey: getenv("PB_S3_ACCESS_KEY", ""),
|
||||||
|
S3Secret: getenv("PB_S3_SECRET", ""),
|
||||||
|
S3ForcePathStyle: boolEnv("PB_S3_FORCE_PATH_STYLE", true),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ package pb
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -29,6 +31,7 @@ type Client struct {
|
|||||||
email string
|
email string
|
||||||
password string
|
password string
|
||||||
token string
|
token string
|
||||||
|
tokenExp time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(baseURL, email, password string) *Client {
|
func New(baseURL, email, password string) *Client {
|
||||||
@@ -69,6 +72,7 @@ func (c *Client) Reconfigure(baseURL, email, password string) {
|
|||||||
c.email = email
|
c.email = email
|
||||||
c.password = password
|
c.password = password
|
||||||
c.token = ""
|
c.token = ""
|
||||||
|
c.tokenExp = time.Time{}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +124,7 @@ func (c *Client) Authenticate(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.token = out.Token
|
c.token = out.Token
|
||||||
|
c.tokenExp = jwtExpiry(out.Token)
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -134,8 +139,46 @@ func (c *Client) currentToken() string {
|
|||||||
return c.token
|
return c.token
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureToken acquires a superuser token when none is cached yet, so that a
|
// tokenSkew is how long before its stated expiry a cached token is treated as
|
||||||
// superuser call never goes out unauthenticated.
|
// spent. It covers clock drift between this server and PocketBase, and the
|
||||||
|
// flight time of a request that passes the check and then arrives just late.
|
||||||
|
const tokenSkew = 60 * time.Second
|
||||||
|
|
||||||
|
// tokenLive reports whether the cached token can still be used. A token with no
|
||||||
|
// readable expiry is taken at face value — the 401 retry remains the backstop.
|
||||||
|
func (c *Client) tokenLive() bool {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if c.token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return c.tokenExp.IsZero() || time.Now().Add(tokenSkew).Before(c.tokenExp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jwtExpiry reads the exp claim out of a PocketBase auth token. The signature is
|
||||||
|
// PocketBase's business — this only needs the expiry the server itself stamped,
|
||||||
|
// so the payload is decoded without verification. Anything unreadable comes back
|
||||||
|
// as the zero time, which tokenLive treats as "no expiry known".
|
||||||
|
func jwtExpiry(token string) time.Time {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &claims); err != nil || claims.Exp == 0 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return time.Unix(claims.Exp, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureToken acquires a superuser token when the cached one is missing or
|
||||||
|
// spent, so that a superuser call never goes out unauthenticated.
|
||||||
//
|
//
|
||||||
// It matters because PocketBase answers a record read it will not allow with
|
// It matters because PocketBase answers a record read it will not allow with
|
||||||
// 404, not 401 — it hides the record rather than refusing the credentials. So a
|
// 404, not 401 — it hides the record rather than refusing the credentials. So a
|
||||||
@@ -149,8 +192,18 @@ func (c *Client) currentToken() string {
|
|||||||
// With no service account configured there is nothing to acquire, so the call
|
// With no service account configured there is nothing to acquire, so the call
|
||||||
// proceeds as before — the endpoints that need superuser access answer 503 on
|
// proceeds as before — the endpoints that need superuser access answer 503 on
|
||||||
// their own.
|
// their own.
|
||||||
|
//
|
||||||
|
// Expiry is checked here rather than left to the 401 retry below, because that
|
||||||
|
// retry never fires for this client: PocketBase does not reject a stale token on
|
||||||
|
// a record call, it ignores the header and serves the request as a guest. The
|
||||||
|
// collection rules then answer instead of the transport — a superuser-only
|
||||||
|
// collection 403s, a rule-guarded record 404s, and a rule-filtered list comes
|
||||||
|
// back 200 with nothing in it. None of those are a 401, so a server whose token
|
||||||
|
// has lapsed keeps sending it and keeps being treated as a stranger to its own
|
||||||
|
// database until it is restarted. Superuser tokens are long-lived, which only
|
||||||
|
// means the failure waits weeks and then arrives as "the app forgot my account".
|
||||||
func (c *Client) ensureToken(ctx context.Context) error {
|
func (c *Client) ensureToken(ctx context.Context) error {
|
||||||
if c.currentToken() != "" || !c.Configured() {
|
if c.tokenLive() || !c.Configured() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return c.Authenticate(ctx)
|
return c.Authenticate(ctx)
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package pb
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PocketBase hides a record it will not let you read behind a 404 rather than a
|
// PocketBase hides a record it will not let you read behind a 404 rather than a
|
||||||
@@ -31,9 +33,14 @@ type fakePB struct {
|
|||||||
email string
|
email string
|
||||||
password string
|
password string
|
||||||
|
|
||||||
|
// When set, tokens are minted as JWTs carrying this lifetime in their exp
|
||||||
|
// claim, the way PocketBase issues them. Zero keeps the opaque test token.
|
||||||
|
jwtTTL time.Duration
|
||||||
|
|
||||||
authCalls int
|
authCalls int
|
||||||
lastIdentity string
|
lastIdentity string
|
||||||
guestAttempts int // record reads that arrived without the superuser token
|
issued string // the token most recently handed out
|
||||||
|
guestAttempts int // record reads that arrived without the superuser token
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakePB) handler() http.Handler {
|
func (f *fakePB) handler() http.Handler {
|
||||||
@@ -51,11 +58,21 @@ func (f *fakePB) handler() http.Handler {
|
|||||||
writeTestJSON(w, 400, map[string]any{"message": "Failed to authenticate."})
|
writeTestJSON(w, 400, map[string]any{"message": "Failed to authenticate."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeTestJSON(w, 200, map[string]any{"token": fakeSvcToken})
|
token := fakeSvcToken
|
||||||
|
if f.jwtTTL > 0 {
|
||||||
|
token = testJWT(time.Now().Add(f.jwtTTL))
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.issued = token
|
||||||
|
f.mu.Unlock()
|
||||||
|
writeTestJSON(w, 200, map[string]any{"token": token})
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/collections/users/records/{id}", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /api/collections/users/records/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get("Authorization") != fakeSvcToken {
|
f.mu.Lock()
|
||||||
|
live := f.issued
|
||||||
|
f.mu.Unlock()
|
||||||
|
if got := r.Header.Get("Authorization"); got == "" || got != live {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.guestAttempts++
|
f.guestAttempts++
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -190,3 +207,101 @@ func TestNoServiceAccountSkipsAuthentication(t *testing.T) {
|
|||||||
t.Errorf("attempted %d sign-in(s) with no credentials, want 0", auth)
|
t.Errorf("attempted %d sign-in(s) with no credentials, want 0", auth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testJWT builds a token shaped like PocketBase's: three base64url segments,
|
||||||
|
// the middle one carrying the exp claim. Only that claim is ever read, so the
|
||||||
|
// header and signature are filler.
|
||||||
|
func testJWT(exp time.Time) string {
|
||||||
|
enc := func(v any) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
return enc(map[string]string{"alg": "HS256", "typ": "JWT"}) + "." +
|
||||||
|
enc(map[string]int64{"exp": exp.Unix()}) + ".sig"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWTExpiry(t *testing.T) {
|
||||||
|
want := time.Now().Add(time.Hour).Truncate(time.Second)
|
||||||
|
if got := jwtExpiry(testJWT(want)); !got.Equal(want) {
|
||||||
|
t.Errorf("jwtExpiry = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
// An opaque (non-JWT) token has no readable expiry, and must not be mistaken
|
||||||
|
// for one that expired at the zero time — tokenLive takes it at face value.
|
||||||
|
for _, tok := range []string{"", "opaque", "a.b", "a.!!.c", "a." + base64.RawURLEncoding.EncodeToString([]byte("{}")) + ".c"} {
|
||||||
|
if got := jwtExpiry(tok); !got.IsZero() {
|
||||||
|
t.Errorf("jwtExpiry(%q) = %v, want zero", tok, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The failure this whole mechanism exists for: a superuser token that has run
|
||||||
|
// out. PocketBase does not answer 401 for one — it ignores the header and serves
|
||||||
|
// the request as a guest, so the 401 retry never fires and the client would go
|
||||||
|
// on presenting a dead token forever. The expiry check has to catch it first.
|
||||||
|
func TestExpiredTokenIsRenewedBeforeUse(t *testing.T) {
|
||||||
|
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
|
||||||
|
c := New(newFakePB(t, f), f.email, f.password)
|
||||||
|
|
||||||
|
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||||
|
t.Fatalf("GetOne: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age the cached token past its expiry, as an uptime longer than the token's
|
||||||
|
// lifetime would.
|
||||||
|
c.mu.Lock()
|
||||||
|
c.tokenExp = time.Now().Add(-time.Minute)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
var rec struct{ ID string }
|
||||||
|
if err := c.GetOne(context.Background(), "users", fakeRecordID, &rec); err != nil {
|
||||||
|
t.Fatalf("GetOne with an expired token: %v", err)
|
||||||
|
}
|
||||||
|
if rec.ID != fakeRecordID {
|
||||||
|
t.Fatalf("record id = %q, want %q", rec.ID, fakeRecordID)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, guest := f.counts()
|
||||||
|
if guest != 0 {
|
||||||
|
t.Errorf("%d record read(s) went out on a dead token, want 0", guest)
|
||||||
|
}
|
||||||
|
if auth != 2 {
|
||||||
|
t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token close enough to its expiry that it could lapse mid-flight is renewed
|
||||||
|
// rather than spent, so a call cannot land just after the token dies.
|
||||||
|
func TestTokenNearingExpiryIsRenewed(t *testing.T) {
|
||||||
|
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
|
||||||
|
c := New(newFakePB(t, f), f.email, f.password)
|
||||||
|
|
||||||
|
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||||
|
t.Fatalf("GetOne: %v", err)
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.tokenExp = time.Now().Add(tokenSkew / 2)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||||
|
t.Fatalf("GetOne near expiry: %v", err)
|
||||||
|
}
|
||||||
|
if auth, _ := f.counts(); auth != 2 {
|
||||||
|
t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token with a real lifetime still ahead of it is reused — the expiry check
|
||||||
|
// must not turn every call into a fresh sign-in.
|
||||||
|
func TestLiveJWTIsReused(t *testing.T) {
|
||||||
|
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
|
||||||
|
c := New(newFakePB(t, f), f.email, f.password)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||||
|
t.Fatalf("GetOne #%d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if auth, _ := f.counts(); auth != 1 {
|
||||||
|
t.Errorf("authenticated %d time(s), want 1", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -334,6 +334,19 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
|
|||||||
{Value: "own", Label: "Own CSMS (full control)"},
|
{Value: "own", Label: "Own CSMS (full control)"},
|
||||||
{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
|
{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
|
||||||
}},
|
}},
|
||||||
|
// controlModesDisabled hides modes from the layers *below* this one. It
|
||||||
|
// is how a superadmin takes a mode that is broken or unwanted in this
|
||||||
|
// deployment (OCPP, say) out of every organization's and user's picker
|
||||||
|
// without touching the mode this layer itself runs. Organizations carry
|
||||||
|
// the same field for their own users; see integrations_ankersolix.go.
|
||||||
|
{Key: "controlModesDisabled", Label: "Hidden control modes", Type: "multiselect",
|
||||||
|
Help: "Control modes to hide from organizations and users. A hidden mode disappears from their picker and stops taking effect for them; the mode chosen above, which is this layer's own, is unaffected. Off (monitoring only) can never be hidden — it is what a charger falls back to.",
|
||||||
|
Options: []plugins.SelectOption{
|
||||||
|
{Value: "mqtt", Label: "Anker cloud (works anywhere)"},
|
||||||
|
{Value: "modbus", Label: "Modbus TCP (local network)"},
|
||||||
|
{Value: "own", Label: "Own CSMS (full control)"},
|
||||||
|
{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,31 @@ func TestDescriptor(t *testing.T) {
|
|||||||
t.Errorf("controlMode is missing option %q", v)
|
t.Errorf("controlMode is missing option %q", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// controlModesDisabled is the superadmin's hide-list: which of the modes the
|
||||||
|
// organizations and users below are offered at all. off is not among them —
|
||||||
|
// monitoring only is the fallback, so no layer may take it away.
|
||||||
|
hide, ok := fields["controlModesDisabled"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("controlModesDisabled config field should be present")
|
||||||
|
}
|
||||||
|
if hide.Type != "multiselect" {
|
||||||
|
t.Errorf("controlModesDisabled type = %q, want multiselect", hide.Type)
|
||||||
|
}
|
||||||
|
hideable := map[string]bool{"mqtt": false, "modbus": false, "own": false, "proxy": false}
|
||||||
|
for _, o := range hide.Options {
|
||||||
|
if o.Value == "off" {
|
||||||
|
t.Error("off must not be hideable — it is what a charger falls back to")
|
||||||
|
}
|
||||||
|
if _, known := hideable[o.Value]; known {
|
||||||
|
hideable[o.Value] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for v, seen := range hideable {
|
||||||
|
if !seen {
|
||||||
|
t.Errorf("controlModesDisabled is missing option %q", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistered(t *testing.T) {
|
func TestRegistered(t *testing.T) {
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ const (
|
|||||||
StatusDown = "down"
|
StatusDown = "down"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SelectOption is one choice for a ConfigField of Type "select".
|
// SelectOption is one choice for a ConfigField of Type "select" (pick one) or
|
||||||
|
// "multiselect" (pick any number; stored as a comma-separated list of values).
|
||||||
type SelectOption struct {
|
type SelectOption struct {
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
@@ -55,12 +56,12 @@ type SelectOption struct {
|
|||||||
type ConfigField struct {
|
type ConfigField struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Type string `json:"type"` // "text" | "password" | "number" | "select"
|
Type string `json:"type"` // "text" | "password" | "number" | "select" | "multiselect"
|
||||||
Required bool `json:"required"`
|
Required bool `json:"required"`
|
||||||
Secret bool `json:"secret"` // never echoed back to clients in clear
|
Secret bool `json:"secret"` // never echoed back to clients in clear
|
||||||
Help string `json:"help,omitempty"`
|
Help string `json:"help,omitempty"`
|
||||||
Default string `json:"default,omitempty"` // effective default when unset
|
Default string `json:"default,omitempty"` // effective default when unset
|
||||||
Options []SelectOption `json:"options,omitempty"` // for Type "select"
|
Options []SelectOption `json:"options,omitempty"` // for Type "select" and "multiselect"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capability is one operation a plugin exposes. It maps a stable id to the
|
// Capability is one operation a plugin exposes. It maps a stable id to the
|
||||||
|
|||||||
@@ -62,6 +62,31 @@ function expand(p) {
|
|||||||
open.value = p.name;
|
open.value = p.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A multiselect field is a comma-separated list of option values in the same
|
||||||
|
// flat string map every other field uses, so the PUT body stays unchanged.
|
||||||
|
function multiValues(name, key) {
|
||||||
|
return String(drafts[name]?.[key] ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
function hasMulti(name, key, value) {
|
||||||
|
return multiValues(name, key).includes(value);
|
||||||
|
}
|
||||||
|
function toggleMulti(name, key, value, on) {
|
||||||
|
const set = new Set(multiValues(name, key));
|
||||||
|
if (on) set.add(value);
|
||||||
|
else set.delete(value);
|
||||||
|
// Keep the declared option order rather than click order, so the stored value
|
||||||
|
// is stable across edits.
|
||||||
|
const p = plugins.value.find((x) => x.name === name);
|
||||||
|
const order = (p?.configFields || []).find((f) => f.key === key)?.options || [];
|
||||||
|
drafts[name][key] = order
|
||||||
|
.map((o) => o.value)
|
||||||
|
.filter((v) => set.has(v))
|
||||||
|
.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
async function save(p, enabled) {
|
async function save(p, enabled) {
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
rowNotice[p.name] = "";
|
rowNotice[p.name] = "";
|
||||||
@@ -224,7 +249,25 @@ const healthClass = (s) =>
|
|||||||
<label class="dh-label">
|
<label class="dh-label">
|
||||||
{{ f.label || f.key }}<span v-if="f.required" class="text-danger"> *</span>
|
{{ f.label || f.key }}<span v-if="f.required" class="text-danger"> *</span>
|
||||||
</label>
|
</label>
|
||||||
<select v-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select">
|
<!-- multiselect: any number of the declared options, stored as a
|
||||||
|
comma-separated list. Nothing checked means the field imposes
|
||||||
|
nothing, which is the unset state for a select too. -->
|
||||||
|
<div v-if="f.type === 'multiselect'" class="flex flex-col gap-1.5 pt-1">
|
||||||
|
<label
|
||||||
|
v-for="o in f.options || []"
|
||||||
|
:key="o.value"
|
||||||
|
class="flex items-center gap-2 text-sm text-body"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="dh-checkbox"
|
||||||
|
:checked="hasMulti(p.name, f.key, o.value)"
|
||||||
|
@change="toggleMulti(p.name, f.key, o.value, $event.target.checked)"
|
||||||
|
/>
|
||||||
|
<span>{{ o.label || o.value }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<select v-else-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select">
|
||||||
<!-- A non-required select can be left unset (empty), so the global
|
<!-- A non-required select can be left unset (empty), so the global
|
||||||
layer abstains and lower layers (org / user) may choose. -->
|
layer abstains and lower layers (org / user) may choose. -->
|
||||||
<option v-if="!f.required" value="">{{ t("plugins.notSet") }}</option>
|
<option v-if="!f.required" value="">{{ t("plugins.notSet") }}</option>
|
||||||
|
|||||||
@@ -293,6 +293,20 @@ body {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
/* Checkbox for multiselect config fields. accent-color keeps the native control
|
||||||
|
(and its keyboard behaviour) while tinting it to the panel's accent. */
|
||||||
|
.dh-checkbox {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: none;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dh-checkbox:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
.dh-label {
|
.dh-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# DriverVault all-in-one — production config.
|
||||||
|
# Copy to .env and fill in, then:
|
||||||
|
# docker compose -f docker-compose.prod.s3.yml pull
|
||||||
|
# docker compose -f docker-compose.prod.s3.yml up -d
|
||||||
|
|
||||||
|
# --- Registry image ----------------------------------------------------------
|
||||||
|
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
|
||||||
|
|
||||||
|
# --- PocketBase superuser (required) -----------------------------------------
|
||||||
|
# Created/updated on first boot. The API Server uses these to manage the database.
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# Allowed CORS origin(s) — match your public web URL / WEB_PORT.
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. This image serves plain HTTP, so
|
||||||
|
# terminate TLS in a reverse proxy in front of it and set OCPP_PUBLIC_URL to the
|
||||||
|
# public wss:// base the charger should be pointed at. Turning the check off is
|
||||||
|
# for trusted networks only.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
|
WEB_PORT=8090
|
||||||
|
PB_PORT=8070
|
||||||
|
API_PORT=8080
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the value below is re-applied on every restart and wins. Set it
|
||||||
|
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||||
|
# own PocketBase and is not meant to be repointed.)
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||||
|
# serves it on port 80 inside this container.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# WEBAPP_URL=http://127.0.0.1:80
|
||||||
|
|
||||||
|
# --- Storage -----------------------------------------------------------------
|
||||||
|
# One Docker-managed named volume by default. Set it to an absolute host path
|
||||||
|
# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||||
|
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
|
||||||
|
# image: the API Server keeps no state on disk, so everything it owns (plugin
|
||||||
|
# settings included) is backed up by backing up this one path.
|
||||||
|
PB_DATA=pb_data
|
||||||
|
|
||||||
|
# --- File storage: external S3 -----------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
|
||||||
|
# already exist. For a gateway on this Docker host use
|
||||||
|
# http://host.docker.internal:8333 — the compose file adds the host entry that
|
||||||
|
# makes that name resolve inside the containers.
|
||||||
|
PB_S3_ENDPOINT=http://10.2.1.10:8333
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION=us-east-1
|
||||||
|
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||||
|
PB_S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# DriverVault all-in-one — production config, SeaweedFS built into the same
|
||||||
|
# image (Dockerfile.seaweedfs). Copy to .env and fill in, then:
|
||||||
|
# docker compose -f docker-compose.prod.seaweedfs.yml build
|
||||||
|
# docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||||
|
# The build needs the repo checkout; to deploy elsewhere, `push` after building
|
||||||
|
# and `pull` on the deploy host.
|
||||||
|
|
||||||
|
# --- Image -------------------------------------------------------------------
|
||||||
|
# The tag the build produces and `push`/`pull` use. Its own name, not
|
||||||
|
# drivervault-aio: this image carries SeaweedFS and expects two volumes.
|
||||||
|
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio-seaweedfs:latest
|
||||||
|
# Build args, both pinned in the Dockerfile. Set to override at build time.
|
||||||
|
# PB_VERSION=0.39.11
|
||||||
|
# SEAWEED_VERSION=4.45
|
||||||
|
|
||||||
|
# --- PocketBase superuser (required) -----------------------------------------
|
||||||
|
# Created/updated on first boot. The API Server uses these to manage the database.
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# Allowed CORS origin(s) — match your public web URL / WEB_PORT.
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. This image serves plain HTTP, so
|
||||||
|
# terminate TLS in a reverse proxy in front of it and set OCPP_PUBLIC_URL to the
|
||||||
|
# public wss:// base the charger should be pointed at. Turning the check off is
|
||||||
|
# for trusted networks only.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
|
WEB_PORT=8090
|
||||||
|
PB_PORT=8070
|
||||||
|
API_PORT=8080
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the value below is re-applied on every restart and wins. Set it
|
||||||
|
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||||
|
# own PocketBase and is not meant to be repointed.)
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||||
|
# serves it on port 80 inside this container.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# WEBAPP_URL=http://127.0.0.1:80
|
||||||
|
|
||||||
|
# --- Storage -----------------------------------------------------------------
|
||||||
|
# One Docker-managed named volume by default. Set it to an absolute host path
|
||||||
|
# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||||
|
# PB_DATA — the PocketBase database and its backups. The API Server keeps no
|
||||||
|
# state on disk, so everything it owns (plugin settings included) is backed up
|
||||||
|
# by backing up this one path — together with SEAWEED_DATA below.
|
||||||
|
PB_DATA=pb_data
|
||||||
|
|
||||||
|
# --- File storage: SeaweedFS -------------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# The credentials do double duty: they configure the SeaweedFS gateway's single
|
||||||
|
# identity *and* are what PocketBase authenticates with. There are no safe
|
||||||
|
# defaults, and the container refuses to start without them.
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# The bucket. Created on first boot by PocketBase's program, before it serves.
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
# The endpoint, region and path style are fixed by the image — the gateway is
|
||||||
|
# inside the container at a loopback address that cannot change.
|
||||||
|
|
||||||
|
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
|
||||||
|
# by default; set an absolute host path for a bind mount, the same way PB_DATA
|
||||||
|
# works above. Back it up alongside PB_DATA: from here on the attachments live
|
||||||
|
# here, not in the database volume.
|
||||||
|
# It is mounted at /seaweed/data, the layout `weed server -dir` writes — the
|
||||||
|
# same one the .split. files use, so the two are interchangeable on it.
|
||||||
|
SEAWEED_DATA=seaweed_data
|
||||||
|
# The S3 port is published on loopback only — PocketBase reaches the gateway
|
||||||
|
# inside the container, and this is for tools like aws-cli. Set
|
||||||
|
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
|
||||||
|
# SEAWEED_S3_BIND=127.0.0.1
|
||||||
|
# SEAWEED_S3_PORT=8333
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# DriverVault all-in-one — production config, SeaweedFS split into its roles and
|
||||||
|
# built into the same image (Dockerfile.seaweedfs.split). Copy to .env and fill
|
||||||
|
# in, then:
|
||||||
|
# docker compose -f docker-compose.prod.seaweedfs.split.yml build
|
||||||
|
# docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
|
||||||
|
# The build needs the repo checkout; to deploy elsewhere, `push` after building
|
||||||
|
# and `pull` on the deploy host.
|
||||||
|
|
||||||
|
# --- Image -------------------------------------------------------------------
|
||||||
|
# The tag the build produces and `push`/`pull` use. Its own name, not
|
||||||
|
# drivervault-aio: this image carries SeaweedFS and expects three volumes.
|
||||||
|
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio-seaweedfs-split:latest
|
||||||
|
# Build args, both pinned in the Dockerfile. Set to override at build time.
|
||||||
|
# PB_VERSION=0.39.11
|
||||||
|
# SEAWEED_VERSION=4.45
|
||||||
|
|
||||||
|
# --- PocketBase superuser (required) -----------------------------------------
|
||||||
|
# Created/updated on first boot. The API Server uses these to manage the database.
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# Allowed CORS origin(s) — match your public web URL / WEB_PORT.
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. This image serves plain HTTP, so
|
||||||
|
# terminate TLS in a reverse proxy in front of it and set OCPP_PUBLIC_URL to the
|
||||||
|
# public wss:// base the charger should be pointed at. Turning the check off is
|
||||||
|
# for trusted networks only.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
|
WEB_PORT=8090
|
||||||
|
PB_PORT=8070
|
||||||
|
API_PORT=8080
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the value below is re-applied on every restart and wins. Set it
|
||||||
|
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||||
|
# own PocketBase and is not meant to be repointed.)
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||||
|
# serves it on port 80 inside this container.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# WEBAPP_URL=http://127.0.0.1:80
|
||||||
|
|
||||||
|
# --- Storage -----------------------------------------------------------------
|
||||||
|
# One Docker-managed named volume by default. Set it to an absolute host path
|
||||||
|
# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||||
|
# PB_DATA — the PocketBase database and its backups. The API Server keeps no
|
||||||
|
# state on disk, so everything it owns (plugin settings included) is backed up
|
||||||
|
# by backing up this one path — together with SEAWEED_DATA below.
|
||||||
|
PB_DATA=pb_data
|
||||||
|
|
||||||
|
# --- File storage: SeaweedFS -------------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# The credentials do double duty: the S3 gateway's program writes them into the
|
||||||
|
# filer's IAM store as the identity named "drivervault" *and* they are what
|
||||||
|
# PocketBase authenticates with. There are no safe defaults, and the container
|
||||||
|
# refuses to start without them. Change them here and restart to rotate: the
|
||||||
|
# seed updates the identity in place rather than adding a second one.
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# The bucket. Created on first boot by the S3 gateway's program.
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
# The endpoint, region and path style are fixed by the image — the gateway is
|
||||||
|
# inside the container at a loopback address that cannot change.
|
||||||
|
|
||||||
|
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
|
||||||
|
# by default; set an absolute host path for a bind mount, the same way PB_DATA
|
||||||
|
# works above. Back it up alongside PB_DATA: from here on the attachments live
|
||||||
|
# here, not in the database volume.
|
||||||
|
#
|
||||||
|
# Master, volume and filer share it (mounted at /seaweed/data), which is the
|
||||||
|
# layout `weed server -dir` writes — so this file and
|
||||||
|
# docker-compose.prod.seaweedfs.yml are interchangeable on the same volume, with
|
||||||
|
# nothing to migrate either way.
|
||||||
|
SEAWEED_DATA=seaweed_data
|
||||||
|
# The S3 port is published on loopback only — PocketBase reaches the gateway
|
||||||
|
# inside the container, and this is for tools like aws-cli. Set
|
||||||
|
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
|
||||||
|
# SEAWEED_S3_BIND=127.0.0.1
|
||||||
|
# SEAWEED_S3_PORT=8333
|
||||||
|
#
|
||||||
|
# The master, volume and filer listen on the container's loopback and publish
|
||||||
|
# no host port at all. The admin UI below shows what they would: the volume
|
||||||
|
# server in particular serves file content by id with no authentication. Reach
|
||||||
|
# them with `docker compose exec drivervault wget -qO- http://127.0.0.1:9333/...`
|
||||||
|
# (volume 8081, filer 8888).
|
||||||
|
|
||||||
|
# --- SeaweedFS admin UI ------------------------------------------------------
|
||||||
|
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
|
||||||
|
# Users, where further S3 identities are created and revoked. They land in the
|
||||||
|
# filer's IAM store, the same one seeded above, and the gateway picks them up
|
||||||
|
# without a restart.
|
||||||
|
#
|
||||||
|
# REQUIRED: weed disables authentication entirely when the password is empty,
|
||||||
|
# and this panel can mint credentials for the bucket — so the image refuses to
|
||||||
|
# start the panel at all without one, and the container stays unhealthy.
|
||||||
|
SEAWEED_ADMIN_USER=admin
|
||||||
|
SEAWEED_ADMIN_PASSWORD=
|
||||||
|
# Optional view-only login.
|
||||||
|
SEAWEED_ADMIN_READONLY_USER=
|
||||||
|
SEAWEED_ADMIN_READONLY_PASSWORD=
|
||||||
|
# Bound to localhost by default, the same call SEAWEED_S3_BIND makes: storage
|
||||||
|
# plumbing, not one of the app's own panels. On a remote host that means
|
||||||
|
# unreachable — set 0.0.0.0 and put it behind a reverse proxy.
|
||||||
|
SEAWEED_ADMIN_BIND=127.0.0.1
|
||||||
|
SEAWEED_ADMIN_PORT=23646
|
||||||
|
# Its own small volume: session key and maintenance-task state, no object data.
|
||||||
|
SEAWEED_ADMIN_DATA=seaweed_admin
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Copy to .env and fill in. Used by the Docker-AIO docker-compose.s3.yml.
|
||||||
|
|
||||||
|
# --- Required (no defaults) --------------------------------------------------
|
||||||
|
# PocketBase superuser, also used by the API Server to authenticate.
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# Allowed CORS origin(s) — match your web origin / WEB_PORT.
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. This image serves plain HTTP:
|
||||||
|
# either terminate TLS in front of it and set OCPP_PUBLIC_URL to the public
|
||||||
|
# wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
|
WEB_PORT=8090
|
||||||
|
PB_PORT=8070
|
||||||
|
# API Server + its embedded web panel (served at the API root, http://host:8080/).
|
||||||
|
API_PORT=8080
|
||||||
|
|
||||||
|
# --- Build args (optional) ---------------------------------------------------
|
||||||
|
# Leave empty so the browser uses same-origin /api (proxied by nginx).
|
||||||
|
VITE_API_BASE=
|
||||||
|
# PocketBase version. The Dockerfile already pins one; set this only to build a
|
||||||
|
# different version. Leaving it commented out keeps the pin (an empty value here
|
||||||
|
# is passed through as-is and would resolve the latest release at build time).
|
||||||
|
#PB_VERSION=0.39.11
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the value below is re-applied on every restart and wins. Set it
|
||||||
|
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||||
|
# own PocketBase and is not meant to be repointed.)
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||||
|
# serves it on port 80 inside this container.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# WEBAPP_URL=http://127.0.0.1:80
|
||||||
|
|
||||||
|
# --- File storage: external S3 -----------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
|
||||||
|
# already exist. For a gateway on this Docker host use
|
||||||
|
# http://host.docker.internal:8333 — the compose file adds the host entry that
|
||||||
|
# makes that name resolve inside the containers.
|
||||||
|
PB_S3_ENDPOINT=http://host.docker.internal:8333
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION=us-east-1
|
||||||
|
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||||
|
PB_S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Copy to .env and fill in. Used by the Docker-AIO docker-compose.seaweedfs.yml.
|
||||||
|
|
||||||
|
# --- Required (no defaults) --------------------------------------------------
|
||||||
|
# PocketBase superuser, also used by the API Server to authenticate.
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# Allowed CORS origin(s) — match your web origin / WEB_PORT.
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. This image serves plain HTTP:
|
||||||
|
# either terminate TLS in front of it and set OCPP_PUBLIC_URL to the public
|
||||||
|
# wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
|
WEB_PORT=8090
|
||||||
|
PB_PORT=8070
|
||||||
|
# API Server + its embedded web panel (served at the API root, http://host:8080/).
|
||||||
|
API_PORT=8080
|
||||||
|
|
||||||
|
# --- Build args (optional) ---------------------------------------------------
|
||||||
|
# Leave empty so the browser uses same-origin /api (proxied by nginx).
|
||||||
|
VITE_API_BASE=
|
||||||
|
# PocketBase version. The Dockerfile already pins one; set this only to build a
|
||||||
|
# different version. Leaving it commented out keeps the pin (an empty value here
|
||||||
|
# is passed through as-is and would resolve the latest release at build time).
|
||||||
|
#PB_VERSION=0.39.11
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the value below is re-applied on every restart and wins. Set it
|
||||||
|
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||||
|
# own PocketBase and is not meant to be repointed.)
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||||
|
# serves it on port 80 inside this container.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# WEBAPP_URL=http://127.0.0.1:80
|
||||||
|
|
||||||
|
# --- File storage: SeaweedFS -------------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# The credentials do double duty: they configure the SeaweedFS gateway's single
|
||||||
|
# identity *and* are what PocketBase authenticates with. There are no safe
|
||||||
|
# defaults, and the stack refuses to start without them.
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION=us-east-1
|
||||||
|
# The gateway image, pinned so a rebuild months from now brings up the same one.
|
||||||
|
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||||
|
# Host port for the S3 API, so aws-cli and friends can reach it while developing.
|
||||||
|
# SEAWEED_S3_PORT=8333
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Copy to .env and fill in. Used by the Docker-AIO
|
||||||
|
# docker-compose.seaweedfs.split.yml — the same stack as .env.seaweedfs.example,
|
||||||
|
# with SeaweedFS running as separate master / volume / filer / S3 containers
|
||||||
|
# plus the SeaweedFS admin UI.
|
||||||
|
|
||||||
|
# --- Required (no defaults) --------------------------------------------------
|
||||||
|
# PocketBase superuser, also used by the API Server to authenticate.
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# Allowed CORS origin(s) — match your web origin / WEB_PORT.
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. This image serves plain HTTP:
|
||||||
|
# either terminate TLS in front of it and set OCPP_PUBLIC_URL to the public
|
||||||
|
# wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
|
WEB_PORT=8090
|
||||||
|
PB_PORT=8070
|
||||||
|
# API Server + its embedded web panel (served at the API root, http://host:8080/).
|
||||||
|
API_PORT=8080
|
||||||
|
|
||||||
|
# --- Build args (optional) ---------------------------------------------------
|
||||||
|
# Leave empty so the browser uses same-origin /api (proxied by nginx).
|
||||||
|
VITE_API_BASE=
|
||||||
|
# PocketBase version. The Dockerfile already pins one; set this only to build a
|
||||||
|
# different version. Leaving it commented out keeps the pin (an empty value here
|
||||||
|
# is passed through as-is and would resolve the latest release at build time).
|
||||||
|
#PB_VERSION=0.39.11
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the value below is re-applied on every restart and wins. Set it
|
||||||
|
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||||
|
# own PocketBase and is not meant to be repointed.)
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||||
|
# serves it on port 80 inside this container.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# WEBAPP_URL=http://127.0.0.1:80
|
||||||
|
|
||||||
|
# --- File storage: SeaweedFS -------------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# The credentials do double duty: seaweedfs-init writes them into the filer's
|
||||||
|
# IAM store as the identity named "drivervault" *and* they are what PocketBase
|
||||||
|
# authenticates with. There are no safe defaults, and the stack refuses to start
|
||||||
|
# without them. Change them here and restart to rotate: the seed updates the
|
||||||
|
# identity in place rather than adding a second one.
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION=us-east-1
|
||||||
|
# The SeaweedFS image, pinned so a rebuild months from now brings up the same
|
||||||
|
# one. All five SeaweedFS containers run it.
|
||||||
|
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||||
|
|
||||||
|
# --- SeaweedFS admin UI ------------------------------------------------------
|
||||||
|
# http://localhost:23646 — cluster topology, volumes, buckets, and
|
||||||
|
# Object Store → Users, where further S3 identities are created and revoked.
|
||||||
|
# They land in the filer's IAM store, the same one seeded above, and the gateway
|
||||||
|
# picks them up without a restart.
|
||||||
|
#
|
||||||
|
# REQUIRED: weed disables authentication entirely when the password is empty,
|
||||||
|
# and this panel can mint credentials for the bucket.
|
||||||
|
SEAWEED_ADMIN_USER=admin
|
||||||
|
SEAWEED_ADMIN_PASSWORD=
|
||||||
|
# SEAWEED_ADMIN_PORT=23646
|
||||||
|
|
||||||
|
# --- SeaweedFS host ports (optional; defaults shown) -------------------------
|
||||||
|
# Published for aws-cli, `weed shell` and poking around while developing. The
|
||||||
|
# stack itself reaches every one of these over the compose network.
|
||||||
|
# Note SEAWEED_VOLUME_PORT: the volume server serves file content by id with NO
|
||||||
|
# authentication, so do not carry this mapping over to a shared machine. It
|
||||||
|
# lands on 8081 because API_PORT already has 8080.
|
||||||
|
# SEAWEED_MASTER_PORT=9333
|
||||||
|
# SEAWEED_VOLUME_PORT=8081
|
||||||
|
# SEAWEED_FILER_PORT=8888
|
||||||
|
# SEAWEED_S3_PORT=8333
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# All-in-one image WITH the object store inside: PocketBase + API Server + Web
|
||||||
|
# App, plus SeaweedFS as one `weed server -s3` process — master, volume, filer
|
||||||
|
# and S3 gateway as goroutines — under supervisord in the same container.
|
||||||
|
#
|
||||||
|
# This is Dockerfile with the two SeaweedFS containers of
|
||||||
|
# docker-compose.prod.seaweedfs.yml folded in. That compose file kept them
|
||||||
|
# outside the image so SeaweedFS could be upgraded without a rebuild; this file
|
||||||
|
# trades that away for a single image and a single container. What it costs: a
|
||||||
|
# new SeaweedFS means a rebuild (--build-arg SEAWEED_VERSION). For per-role
|
||||||
|
# restart, metrics and the admin UI, see Dockerfile.seaweedfs.split.
|
||||||
|
#
|
||||||
|
# The build context MUST be the project root so this file can reach both
|
||||||
|
# "API Server/" and "Web App/". The root .dockerignore is an allow-list of the
|
||||||
|
# paths copied below — add to it if you add a COPY here. Build it with:
|
||||||
|
#
|
||||||
|
# docker build -f "Docker-AIO/Dockerfile.seaweedfs" -t drivervault-aio-seaweedfs .
|
||||||
|
#
|
||||||
|
# Run it (everything starts together):
|
||||||
|
#
|
||||||
|
# docker run -d --name drivervault -p 80:80 -p 8070:8070 -p 8080:8080 \
|
||||||
|
# -e PB_ADMIN_EMAIL=admin@example.com \
|
||||||
|
# -e PB_ADMIN_PASSWORD=change-me \
|
||||||
|
# -e PB_S3_ACCESS_KEY=drivervault -e PB_S3_SECRET=change-me \
|
||||||
|
# -v drivervault_pb:/pb/pb_data \
|
||||||
|
# -v drivervault_seaweed:/seaweed/data \
|
||||||
|
# drivervault-aio-seaweedfs
|
||||||
|
#
|
||||||
|
# Then: web app on http://host/ and PocketBase admin on http://host:8070/_/.
|
||||||
|
# On first boot PocketBase's program creates the bucket, and the API Server
|
||||||
|
# creates the collections, the super-admin, and points PocketBase's file
|
||||||
|
# storage at the bucket.
|
||||||
|
#
|
||||||
|
# Port map inside the container (only 80, 8070, 8080 and 8333 are meant to be
|
||||||
|
# published):
|
||||||
|
#
|
||||||
|
# 80 nginx (Web App, /api/ and /ocpp/ proxied)
|
||||||
|
# 8070 PocketBase
|
||||||
|
# 8080 API Server ← which is why the volume server is NOT on its
|
||||||
|
# 8081 SeaweedFS volume usual 8080 here
|
||||||
|
# 8333 SeaweedFS S3 gateway (PocketBase's endpoint; publish on loopback)
|
||||||
|
# 8888 SeaweedFS filer
|
||||||
|
# 9333 SeaweedFS master
|
||||||
|
|
||||||
|
# SeaweedFS release to bake in, pinned like PB_VERSION so a rebuild months from
|
||||||
|
# now brings up the same one. Same tag the compose SeaweedFS files run as
|
||||||
|
# SEAWEED_IMAGE. Override with --build-arg SEAWEED_VERSION=... to upgrade.
|
||||||
|
ARG SEAWEED_VERSION="4.45"
|
||||||
|
|
||||||
|
# --- Stage 1: build the Go API Server ---------------------------------------
|
||||||
|
FROM golang:1.26-alpine3.24 AS api-build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["API Server/go.mod", "./"]
|
||||||
|
COPY ["API Server/go.su[m]", "./"]
|
||||||
|
RUN go mod download
|
||||||
|
# Only cmd/ + internal are needed; the panel is already built into
|
||||||
|
# internal/api/dist and embedded via //go:embed. Entry point is cmd/server.
|
||||||
|
COPY ["API Server/cmd", "./cmd"]
|
||||||
|
COPY ["API Server/internal", "./internal"]
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-server ./cmd/server
|
||||||
|
|
||||||
|
# --- Stage 2: build the Vue Web App -----------------------------------------
|
||||||
|
# The Vue source lives under "Web App/web/".
|
||||||
|
FROM node:22-alpine3.24 AS web-build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ["Web App/web/package.json", "Web App/web/package-lock.json", "./"]
|
||||||
|
RUN npm ci
|
||||||
|
COPY ["Web App/web/index.html", "Web App/web/vite.config.js", "./"]
|
||||||
|
COPY ["Web App/web/src", "./src"]
|
||||||
|
COPY ["Web App/web/public", "./public"]
|
||||||
|
# Empty -> bundle uses same-origin "/api", proxied to the API Server by nginx.
|
||||||
|
ARG VITE_API_BASE
|
||||||
|
# vite.config writes to ../server/dist by default; emit into ./dist here.
|
||||||
|
RUN npm run build -- --outDir dist --emptyOutDir
|
||||||
|
|
||||||
|
# --- Stage 3: SeaweedFS -----------------------------------------------------
|
||||||
|
# The one static binary from the official image. SEAWEED_VERSION is declared at
|
||||||
|
# the top of the file: an ARG used in a FROM has to be global, and one declared
|
||||||
|
# here would belong to the stage above and expand to nothing.
|
||||||
|
FROM chrislusf/seaweedfs:${SEAWEED_VERSION} AS seaweed
|
||||||
|
|
||||||
|
# --- Stage 4: runtime (all services) ----------------------------------------
|
||||||
|
FROM alpine:3.24
|
||||||
|
|
||||||
|
# Pinned so a rebuild months from now produces the same PocketBase. Override to
|
||||||
|
# upgrade (--build-arg PB_VERSION=0.40.0); set it to empty to resolve the latest
|
||||||
|
# release at build time, which needs an unauthenticated GitHub API call and is
|
||||||
|
# therefore subject to that GitHub rate limit (60/hour per IP).
|
||||||
|
ARG PB_VERSION="0.39.11"
|
||||||
|
# Provided automatically by BuildKit (amd64 / arm64).
|
||||||
|
ARG TARGETARCH="amd64"
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata unzip wget nginx supervisor \
|
||||||
|
&& mkdir -p /run/nginx
|
||||||
|
|
||||||
|
# Unprivileged account for PocketBase, the API Server and SeaweedFS. Only nginx
|
||||||
|
# stays root, because it binds port 80; supervisord drops to this user for
|
||||||
|
# everything else.
|
||||||
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
|
||||||
|
# PocketBase from the official release (pinned via PB_VERSION, else latest).
|
||||||
|
WORKDIR /pb
|
||||||
|
RUN set -eux; \
|
||||||
|
ver="${PB_VERSION}"; \
|
||||||
|
if [ -z "$ver" ]; then \
|
||||||
|
ver="$(wget -qO- https://api.github.com/repos/pocketbase/pocketbase/releases/latest \
|
||||||
|
| grep -o '"tag_name": *"v[^"]*"' | head -1 | sed -E 's/.*"v([^"]+)".*/\1/')"; \
|
||||||
|
fi; \
|
||||||
|
echo "Installing PocketBase v${ver} (${TARGETARCH})"; \
|
||||||
|
wget -q -O /tmp/pb.zip \
|
||||||
|
"https://github.com/pocketbase/pocketbase/releases/download/v${ver}/pocketbase_${ver}_linux_${TARGETARCH}.zip"; \
|
||||||
|
unzip /tmp/pb.zip -d /pb; \
|
||||||
|
rm /tmp/pb.zip
|
||||||
|
|
||||||
|
# API Server binary, built Web App static assets, and the weed binary.
|
||||||
|
COPY --from=api-build /out/api-server /usr/local/bin/api-server
|
||||||
|
COPY --from=web-build /app/dist /usr/share/nginx/html
|
||||||
|
COPY --from=seaweed /usr/bin/weed /usr/local/bin/weed
|
||||||
|
|
||||||
|
# WebSocket handshakes need Connection/Upgrade forwarded, and the map deriving
|
||||||
|
# them must sit in the http context, not inside a server block. It goes in
|
||||||
|
# http.d/ (which Alpine nginx includes from http{}; it does not read conf.d/),
|
||||||
|
# and the 00- prefix keeps it ahead of default.conf in the include order.
|
||||||
|
RUN cat > /etc/nginx/http.d/00-upgrade.conf <<'NGINXMAP'
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
NGINXMAP
|
||||||
|
|
||||||
|
# nginx: serve the SPA and proxy /api/ + /ocpp/ to the API Server on localhost.
|
||||||
|
RUN cat > /etc/nginx/http.d/default.conf <<'NGINX'
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# A real liveness route, so the SPA fallback below cannot answer a health
|
||||||
|
# probe with index.html and make a broken container look healthy.
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 200 "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
# Forward WebSocket upgrades instead of silently stripping them.
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# The chargers' door, not the browser's. The API Server tells a charger to
|
||||||
|
# dial the host it was itself asked on — this one — so without this location
|
||||||
|
# the SPA fallback would answer the WebSocket handshake with index.html.
|
||||||
|
location /ocpp/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# A charging session is idle between heartbeats; the default 60s would
|
||||||
|
# close it under the charger.
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_send_timeout 1h;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
|
||||||
|
# supervisord runs the four processes and keeps them alive. Priorities only
|
||||||
|
# order the launches; readiness is the `until wget` loop in front of each
|
||||||
|
# program that needs another one up, the same chain the compose file spells out
|
||||||
|
# with depends_on + healthchecks: SeaweedFS → bucket → PocketBase → API Server.
|
||||||
|
RUN cat > /etc/supervisord.conf <<'SUPERVISOR'
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
pidfile=/run/supervisord.pid
|
||||||
|
logfile=/dev/null
|
||||||
|
logfile_maxbytes=0
|
||||||
|
|
||||||
|
; SeaweedFS: one process, four roles — master, volume, filer and the S3
|
||||||
|
; gateway. -dir is the only state it keeps. -ip=127.0.0.1 is the address the
|
||||||
|
; roles advertise to each other, which is where they are; -ip.bind stays open
|
||||||
|
; so the gateway can be published (weed server has one bind flag for all four,
|
||||||
|
; and the volume server, on 8081 because 8080 is the API Server, serves file
|
||||||
|
; content by id with no authentication — so never publish 8081).
|
||||||
|
;
|
||||||
|
; SeaweedFS falls back to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY when started
|
||||||
|
; without an -s3.config file, and configuring one identity is what takes the
|
||||||
|
; gateway out of its default allow-anyone mode. They are the PB_S3_* pair
|
||||||
|
; PocketBase authenticates with, mapped here so there is one pair to set — and
|
||||||
|
; an empty pair means no gateway rather than an open one.
|
||||||
|
[program:seaweedfs]
|
||||||
|
user=app
|
||||||
|
command=/bin/sh -c 'if [ -z "$PB_S3_ACCESS_KEY" ] || [ -z "$PB_S3_SECRET" ]; then echo "seaweedfs: PB_S3_ACCESS_KEY and PB_S3_SECRET are required; refusing to start an unauthenticated gateway" >&2; exit 1; fi; export AWS_ACCESS_KEY_ID="$PB_S3_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$PB_S3_SECRET"; exec /usr/local/bin/weed server -dir=/seaweed/data -ip=127.0.0.1 -ip.bind=0.0.0.0 -volume.port=8081 -s3 -master.volumeSizeLimitMB=1024'
|
||||||
|
priority=1
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; PocketBase: wait for the S3 gateway — it is the process that reads and writes
|
||||||
|
; the objects, so the bucket has to be serving before it does, exactly as the
|
||||||
|
; compose file gates the whole container on the gateway's health. Then create
|
||||||
|
; the bucket: PocketBase never issues a CreateBucket of its own and SeaweedFS
|
||||||
|
; will not conjure one on first upload. Creating a bucket that already exists is
|
||||||
|
; a no-op, and `|| true` keeps a restart from being blocked by the shell's exit
|
||||||
|
; status — a gateway that is genuinely broken is reported by the API Server's
|
||||||
|
; own S3 check at boot, with the reason. Then upsert the superuser (idempotent)
|
||||||
|
; and serve. Runs as the unprivileged app user, which owns /pb and the pb_data
|
||||||
|
; volume.
|
||||||
|
[program:pocketbase]
|
||||||
|
directory=/pb
|
||||||
|
user=app
|
||||||
|
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8333/healthz >/dev/null 2>&1; do echo "waiting for seaweedfs..."; sleep 1; done; echo "s3.bucket.create -name $PB_S3_BUCKET" | /usr/local/bin/weed shell -master=127.0.0.1:9333 || true; /pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8070'
|
||||||
|
priority=10
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; API Server: wait for PocketBase to be healthy, then start. Its bootstrap
|
||||||
|
; points PocketBase's file storage at the bucket and asks it to prove the
|
||||||
|
; gateway is reachable. It keeps no state on disk — plugin settings, like
|
||||||
|
; everything else it owns, live in PocketBase — so its working directory is
|
||||||
|
; just a place to run from.
|
||||||
|
[program:api-server]
|
||||||
|
directory=/app
|
||||||
|
user=app
|
||||||
|
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server'
|
||||||
|
priority=20
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; nginx stays root so it can bind :80; its own workers drop to the nginx user.
|
||||||
|
[program:nginx]
|
||||||
|
command=/usr/sbin/nginx -g 'daemon off;'
|
||||||
|
priority=30
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
SUPERVISOR
|
||||||
|
|
||||||
|
# The entrypoint stays root only long enough to make the data volumes writable
|
||||||
|
# by the app user, then hands off to supervisord. The chown matters for a host
|
||||||
|
# bind mount, which arrives owned by root rather than inheriting the image's
|
||||||
|
# owner.
|
||||||
|
RUN cat > /entrypoint.sh <<'ENTRY'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
for dir in /pb/pb_data /seaweed/data; do
|
||||||
|
mkdir -p "$dir"
|
||||||
|
if [ "$(stat -c %U "$dir" 2>/dev/null)" != "app" ]; then
|
||||||
|
echo "entrypoint: taking ownership of $dir"
|
||||||
|
chown -R app:app "$dir"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exec supervisord -c /etc/supervisord.conf
|
||||||
|
ENTRY
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# API Server config: everything is local to this container. WEBAPP_URL is what
|
||||||
|
# the panel status page probes; nginx serves the Web App on :80 in here, so the
|
||||||
|
# stock default of localhost:8090 would always report the Web App as down.
|
||||||
|
#
|
||||||
|
# File storage is on by construction: the gateway is in this image, at a fixed
|
||||||
|
# loopback address, path style because a self-hosted gateway has no per-bucket
|
||||||
|
# DNS. The region is a formality SeaweedFS ignores and PocketBase insists on.
|
||||||
|
# supervisord passes these through to the API Server, whose bootstrap writes
|
||||||
|
# them into PocketBase's settings on every boot. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos; the database and PocketBase's own backups
|
||||||
|
# stay on /pb/pb_data.
|
||||||
|
ENV API_ADDR=:8080 \
|
||||||
|
POCKETBASE_URL=http://127.0.0.1:8070 \
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090 \
|
||||||
|
AUTH_USERS_COLLECTION=users \
|
||||||
|
WEBAPP_URL=http://127.0.0.1:80 \
|
||||||
|
PB_S3_ENABLED=true \
|
||||||
|
PB_S3_ENDPOINT=http://127.0.0.1:8333 \
|
||||||
|
PB_S3_BUCKET=drivervault \
|
||||||
|
PB_S3_REGION=us-east-1 \
|
||||||
|
PB_S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD;
|
||||||
|
# PB_S3_ACCESS_KEY, PB_S3_SECRET (the gateway's single identity AND what
|
||||||
|
# PocketBase authenticates with — the gateway refuses to start without them).
|
||||||
|
# Optional: DRIVERVAULT_SUPERADMIN_EMAIL / DRIVERVAULT_SUPERADMIN_PASSWORD create
|
||||||
|
# the first app super-admin on boot. PB_BOOTSTRAP=false skips schema setup —
|
||||||
|
# leave it on: a release can add collections or fields the server needs, and a
|
||||||
|
# stack that skips the bootstrap never gets them. (app_settings, which holds the
|
||||||
|
# plugin settings, is created on demand; nothing else is.)
|
||||||
|
# For Anker Solix charger control, OCPP_REQUIRE_TLS (default true) rejects
|
||||||
|
# chargers that did not arrive over TLS — this image serves plain HTTP, so put a
|
||||||
|
# TLS-terminating proxy in front and set OCPP_PUBLIC_URL to the public wss://
|
||||||
|
# base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||||
|
# Pass them with `docker run -e ...`.
|
||||||
|
|
||||||
|
# Two volumes. /pb/pb_data: the database, the uploads made before S3 was on,
|
||||||
|
# PocketBase's own backups, and the server settings — the API Server keeps no
|
||||||
|
# state on disk. /seaweed/data: everything SeaweedFS keeps, in the layout
|
||||||
|
# `weed server -dir` writes — the same one the split image's master, volume and
|
||||||
|
# filer share, so a SEAWEED_DATA volume from any of the SeaweedFS compose files
|
||||||
|
# mounts here unchanged, and vice versa. Both pre-created and owned by app so a
|
||||||
|
# fresh named volume inherits that ownership.
|
||||||
|
RUN mkdir -p /pb/pb_data /seaweed/data /app \
|
||||||
|
&& chown -R app:app /pb /seaweed /app
|
||||||
|
VOLUME ["/pb/pb_data", "/seaweed/data"]
|
||||||
|
# 80 = Web App, 8070 = PocketBase admin, 8080 = API Server + embedded API panel
|
||||||
|
# (also the /ocpp/{serial} endpoint chargers dial into), 8333 = S3 gateway (for
|
||||||
|
# aws-cli and the like; loopback is enough).
|
||||||
|
EXPOSE 80 8070 8080 8333
|
||||||
|
|
||||||
|
# Every process must answer, so a wedged component shows up in `docker ps`
|
||||||
|
# instead of a container that looks up while part of it is dead. start-period
|
||||||
|
# covers SeaweedFS coming up plus the first-boot schema bootstrap on a cold
|
||||||
|
# database.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null 2>&1 \
|
||||||
|
&& wget -qO- http://127.0.0.1:8333/healthz >/dev/null 2>&1 \
|
||||||
|
&& wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1 \
|
||||||
|
&& wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 \
|
||||||
|
&& wget -qO- http://127.0.0.1:80/healthz >/dev/null 2>&1 \
|
||||||
|
|| exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# All-in-one image WITH the object store inside: PocketBase + API Server + Web
|
||||||
|
# App, plus SeaweedFS split into its roles — master, volume, filer, S3 gateway
|
||||||
|
# and the admin UI — each its own supervisord program in the same container.
|
||||||
|
#
|
||||||
|
# This is Dockerfile with the six SeaweedFS containers of
|
||||||
|
# docker-compose.prod.seaweedfs.split.yml folded in. The compose split kept
|
||||||
|
# them outside the image so SeaweedFS could be upgraded without a rebuild; this
|
||||||
|
# file trades that away for a single image and a single container. What the
|
||||||
|
# split still buys in here: per-role restart under supervisord, per-role
|
||||||
|
# metrics ports, and the admin UI, where S3 identities are minted and revoked.
|
||||||
|
# What it costs: a new SeaweedFS means a rebuild (--build-arg SEAWEED_VERSION).
|
||||||
|
#
|
||||||
|
# The build context MUST be the project root so this file can reach both
|
||||||
|
# "API Server/" and "Web App/". The root .dockerignore is an allow-list of the
|
||||||
|
# paths copied below — add to it if you add a COPY here. Build it with:
|
||||||
|
#
|
||||||
|
# docker build -f "Docker-AIO/Dockerfile.seaweedfs.split" -t drivervault-aio-seaweedfs-split .
|
||||||
|
#
|
||||||
|
# Run it (everything starts together):
|
||||||
|
#
|
||||||
|
# docker run -d --name drivervault -p 80:80 -p 8070:8070 -p 8080:8080 \
|
||||||
|
# -p 127.0.0.1:23646:23646 \
|
||||||
|
# -e PB_ADMIN_EMAIL=admin@example.com \
|
||||||
|
# -e PB_ADMIN_PASSWORD=change-me \
|
||||||
|
# -e PB_S3_ACCESS_KEY=drivervault -e PB_S3_SECRET=change-me \
|
||||||
|
# -e WEED_ADMIN_PASSWORD=change-me \
|
||||||
|
# -v drivervault_pb:/pb/pb_data \
|
||||||
|
# -v drivervault_seaweed:/seaweed/data \
|
||||||
|
# -v drivervault_seaweed_admin:/seaweed/admin \
|
||||||
|
# drivervault-aio-seaweedfs-split
|
||||||
|
#
|
||||||
|
# Then: web app on http://host/, PocketBase admin on http://host:8070/_/, and
|
||||||
|
# the SeaweedFS admin UI on http://127.0.0.1:23646/. On first boot the S3
|
||||||
|
# gateway's program creates the bucket and seeds PocketBase's identity, and the
|
||||||
|
# API Server creates the collections, the super-admin, and points PocketBase's
|
||||||
|
# file storage at the bucket.
|
||||||
|
#
|
||||||
|
# Port map inside the container (only 80, 8070, 8080, 8333 and 23646 are meant
|
||||||
|
# to be published; the rest are bound to loopback):
|
||||||
|
#
|
||||||
|
# 80 nginx (Web App, /api/ and /ocpp/ proxied)
|
||||||
|
# 8070 PocketBase
|
||||||
|
# 8080 API Server ← which is why the volume server is NOT on its
|
||||||
|
# 8081 SeaweedFS volume usual 8080 here
|
||||||
|
# 8333 SeaweedFS S3 gateway (PocketBase's endpoint; publish on loopback)
|
||||||
|
# 8888 SeaweedFS filer
|
||||||
|
# 9333 SeaweedFS master
|
||||||
|
# 23646 SeaweedFS admin UI (publish on loopback, behind a proxy if remote)
|
||||||
|
|
||||||
|
# SeaweedFS release to bake in, pinned like PB_VERSION so a rebuild months from
|
||||||
|
# now brings up the same one. Same tag the compose SeaweedFS files run as
|
||||||
|
# SEAWEED_IMAGE. Override with --build-arg SEAWEED_VERSION=... to upgrade.
|
||||||
|
ARG SEAWEED_VERSION="4.45"
|
||||||
|
|
||||||
|
# --- Stage 1: build the Go API Server ---------------------------------------
|
||||||
|
FROM golang:1.26-alpine3.24 AS api-build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["API Server/go.mod", "./"]
|
||||||
|
COPY ["API Server/go.su[m]", "./"]
|
||||||
|
RUN go mod download
|
||||||
|
# Only cmd/ + internal are needed; the panel is already built into
|
||||||
|
# internal/api/dist and embedded via //go:embed. Entry point is cmd/server.
|
||||||
|
COPY ["API Server/cmd", "./cmd"]
|
||||||
|
COPY ["API Server/internal", "./internal"]
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-server ./cmd/server
|
||||||
|
|
||||||
|
# --- Stage 2: build the Vue Web App -----------------------------------------
|
||||||
|
# The Vue source lives under "Web App/web/".
|
||||||
|
FROM node:22-alpine3.24 AS web-build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ["Web App/web/package.json", "Web App/web/package-lock.json", "./"]
|
||||||
|
RUN npm ci
|
||||||
|
COPY ["Web App/web/index.html", "Web App/web/vite.config.js", "./"]
|
||||||
|
COPY ["Web App/web/src", "./src"]
|
||||||
|
COPY ["Web App/web/public", "./public"]
|
||||||
|
# Empty -> bundle uses same-origin "/api", proxied to the API Server by nginx.
|
||||||
|
ARG VITE_API_BASE
|
||||||
|
# vite.config writes to ../server/dist by default; emit into ./dist here.
|
||||||
|
RUN npm run build -- --outDir dist --emptyOutDir
|
||||||
|
|
||||||
|
# --- Stage 3: SeaweedFS -----------------------------------------------------
|
||||||
|
# The one static binary from the official image. SEAWEED_VERSION is declared at
|
||||||
|
# the top of the file: an ARG used in a FROM has to be global, and one declared
|
||||||
|
# here would belong to the stage above and expand to nothing.
|
||||||
|
FROM chrislusf/seaweedfs:${SEAWEED_VERSION} AS seaweed
|
||||||
|
|
||||||
|
# --- Stage 4: runtime (all services) ----------------------------------------
|
||||||
|
FROM alpine:3.24
|
||||||
|
|
||||||
|
# Pinned so a rebuild months from now produces the same PocketBase. Override to
|
||||||
|
# upgrade (--build-arg PB_VERSION=0.40.0); set it to empty to resolve the latest
|
||||||
|
# release at build time, which needs an unauthenticated GitHub API call and is
|
||||||
|
# therefore subject to that GitHub rate limit (60/hour per IP).
|
||||||
|
ARG PB_VERSION="0.39.11"
|
||||||
|
# Provided automatically by BuildKit (amd64 / arm64).
|
||||||
|
ARG TARGETARCH="amd64"
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata unzip wget nginx supervisor \
|
||||||
|
&& mkdir -p /run/nginx
|
||||||
|
|
||||||
|
# Unprivileged account for PocketBase, the API Server and every SeaweedFS role.
|
||||||
|
# Only nginx stays root, because it binds port 80; supervisord drops to this
|
||||||
|
# user for everything else.
|
||||||
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
|
||||||
|
# PocketBase from the official release (pinned via PB_VERSION, else latest).
|
||||||
|
WORKDIR /pb
|
||||||
|
RUN set -eux; \
|
||||||
|
ver="${PB_VERSION}"; \
|
||||||
|
if [ -z "$ver" ]; then \
|
||||||
|
ver="$(wget -qO- https://api.github.com/repos/pocketbase/pocketbase/releases/latest \
|
||||||
|
| grep -o '"tag_name": *"v[^"]*"' | head -1 | sed -E 's/.*"v([^"]+)".*/\1/')"; \
|
||||||
|
fi; \
|
||||||
|
echo "Installing PocketBase v${ver} (${TARGETARCH})"; \
|
||||||
|
wget -q -O /tmp/pb.zip \
|
||||||
|
"https://github.com/pocketbase/pocketbase/releases/download/v${ver}/pocketbase_${ver}_linux_${TARGETARCH}.zip"; \
|
||||||
|
unzip /tmp/pb.zip -d /pb; \
|
||||||
|
rm /tmp/pb.zip
|
||||||
|
|
||||||
|
# API Server binary, built Web App static assets, and the weed binary.
|
||||||
|
COPY --from=api-build /out/api-server /usr/local/bin/api-server
|
||||||
|
COPY --from=web-build /app/dist /usr/share/nginx/html
|
||||||
|
COPY --from=seaweed /usr/bin/weed /usr/local/bin/weed
|
||||||
|
|
||||||
|
# WebSocket handshakes need Connection/Upgrade forwarded, and the map deriving
|
||||||
|
# them must sit in the http context, not inside a server block. It goes in
|
||||||
|
# http.d/ (which Alpine nginx includes from http{}; it does not read conf.d/),
|
||||||
|
# and the 00- prefix keeps it ahead of default.conf in the include order.
|
||||||
|
RUN cat > /etc/nginx/http.d/00-upgrade.conf <<'NGINXMAP'
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
NGINXMAP
|
||||||
|
|
||||||
|
# nginx: serve the SPA and proxy /api/ + /ocpp/ to the API Server on localhost.
|
||||||
|
RUN cat > /etc/nginx/http.d/default.conf <<'NGINX'
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# A real liveness route, so the SPA fallback below cannot answer a health
|
||||||
|
# probe with index.html and make a broken container look healthy.
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 200 "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
# Forward WebSocket upgrades instead of silently stripping them.
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# The chargers' door, not the browser's. The API Server tells a charger to
|
||||||
|
# dial the host it was itself asked on — this one — so without this location
|
||||||
|
# the SPA fallback would answer the WebSocket handshake with index.html.
|
||||||
|
location /ocpp/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# A charging session is idle between heartbeats; the default 60s would
|
||||||
|
# close it under the charger.
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_send_timeout 1h;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
|
||||||
|
# Bucket and identity seed, run by the S3 gateway's program before it serves.
|
||||||
|
# Two jobs, both idempotent, so every boot re-applies the values from the
|
||||||
|
# environment and changes nothing else — which is also how a rotated
|
||||||
|
# PB_S3_SECRET reaches the gateway:
|
||||||
|
#
|
||||||
|
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
|
||||||
|
# 2. write PocketBase's S3 identity into the filer's IAM store.
|
||||||
|
#
|
||||||
|
# (2) is why the gateway below runs with neither an -config file nor
|
||||||
|
# AWS_ACCESS_KEY_ID: the env vars are the lowest-priority credential source in
|
||||||
|
# SeaweedFS, read only while the filer's store is empty, so the first identity
|
||||||
|
# added in the admin UI would silently displace them and lock PocketBase out.
|
||||||
|
# Seeding the store the admin UI itself writes leaves one source of truth, and
|
||||||
|
# PocketBase's key shows under Object Store → Users like any other.
|
||||||
|
#
|
||||||
|
# The closing grep is the gate: an empty IAM store means the gateway would come
|
||||||
|
# up in its allow-anyone default, so this fails loudly instead and the gateway
|
||||||
|
# never starts (supervisord retries it, and the healthcheck stays red).
|
||||||
|
RUN cat > /usr/local/bin/seaweedfs-seed <<'SEED'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
: "${PB_S3_ACCESS_KEY:?seaweedfs-seed: PB_S3_ACCESS_KEY is required}"
|
||||||
|
: "${PB_S3_SECRET:?seaweedfs-seed: PB_S3_SECRET is required}"
|
||||||
|
bucket="${PB_S3_BUCKET:-drivervault}"
|
||||||
|
shell() { weed shell -master=127.0.0.1:9333 -filer=127.0.0.1:8888; }
|
||||||
|
printf '%s\n' \
|
||||||
|
"s3.bucket.create -name $bucket" \
|
||||||
|
"s3.configure -user drivervault -access_key $PB_S3_ACCESS_KEY -secret_key $PB_S3_SECRET -actions Admin -apply" \
|
||||||
|
| shell
|
||||||
|
if ! echo "s3.configure" | shell | grep -q "$PB_S3_ACCESS_KEY"; then
|
||||||
|
echo "seaweedfs-seed: PocketBase's identity is not in the filer's IAM store; refusing to start the gateway" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SEED
|
||||||
|
RUN chmod +x /usr/local/bin/seaweedfs-seed
|
||||||
|
|
||||||
|
# supervisord runs the eight processes and keeps them alive. Priorities only
|
||||||
|
# order the launches; readiness is the `until wget` loop in front of each
|
||||||
|
# program that needs another one up, the same chain the compose split spells
|
||||||
|
# out with depends_on + healthchecks: master → volume → filer → seed + S3 →
|
||||||
|
# PocketBase → API Server.
|
||||||
|
#
|
||||||
|
# Every SeaweedFS role advertises and binds 127.0.0.1 (-ip / -ip.bind): they only
|
||||||
|
# ever talk to each other in here, and the volume server in particular serves
|
||||||
|
# file content by id with no authentication at all. The S3 gateway and the admin
|
||||||
|
# UI bind everywhere so they can be published — on loopback, by the compose
|
||||||
|
# file's default.
|
||||||
|
RUN cat > /etc/supervisord.conf <<'SUPERVISOR'
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
pidfile=/run/supervisord.pid
|
||||||
|
logfile=/dev/null
|
||||||
|
logfile_maxbytes=0
|
||||||
|
|
||||||
|
; SeaweedFS master: volume/topology metadata and file ids.
|
||||||
|
[program:seaweedfs-master]
|
||||||
|
user=app
|
||||||
|
command=/usr/local/bin/weed master -ip=127.0.0.1 -ip.bind=127.0.0.1 -port=9333 -mdir=/seaweed/data -volumeSizeLimitMB=1024 -metricsPort=9324
|
||||||
|
priority=1
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; SeaweedFS volume server: where the bytes land. On 8081 because 8080 is the
|
||||||
|
; API Server in this container. -max=0 sizes itself from free disk rather than
|
||||||
|
; the default cap of 8 volumes.
|
||||||
|
[program:seaweedfs-volume]
|
||||||
|
user=app
|
||||||
|
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null 2>&1; do echo "waiting for seaweedfs master..."; sleep 1; done; exec /usr/local/bin/weed volume -master=127.0.0.1:9333 -ip=127.0.0.1 -ip.bind=127.0.0.1 -port=8081 -dir=/seaweed/data -max=0 -metricsPort=9325'
|
||||||
|
priority=2
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; SeaweedFS filer: the directory tree over the flat volume store — buckets,
|
||||||
|
; object keys — and the IAM store the S3 identities live in. -defaultStoreDir
|
||||||
|
; keeps its embedded leveldb on the data volume so they survive a recreate.
|
||||||
|
[program:seaweedfs-filer]
|
||||||
|
user=app
|
||||||
|
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8081/healthz >/dev/null 2>&1; do echo "waiting for seaweedfs volume..."; sleep 1; done; exec /usr/local/bin/weed filer -master=127.0.0.1:9333 -ip=127.0.0.1 -ip.bind=127.0.0.1 -port=8888 -defaultStoreDir=/seaweed/data -metricsPort=9326'
|
||||||
|
priority=3
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; SeaweedFS S3 gateway: PocketBase's endpoint. Seeds the bucket and identity
|
||||||
|
; first (see /usr/local/bin/seaweedfs-seed) and never serves if that fails. No
|
||||||
|
; -config file: with only -filer given, credentials come from the filer's IAM
|
||||||
|
; store, which is what lets the admin UI add and revoke identities without a
|
||||||
|
; restart.
|
||||||
|
[program:seaweedfs-s3]
|
||||||
|
user=app
|
||||||
|
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8888/healthz >/dev/null 2>&1; do echo "waiting for seaweedfs filer..."; sleep 1; done; /usr/local/bin/seaweedfs-seed && exec /usr/local/bin/weed s3 -filer=127.0.0.1:8888 -ip.bind=0.0.0.0 -port=8333 -metricsPort=9327'
|
||||||
|
priority=4
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; SeaweedFS admin UI: cluster view, buckets, maintenance, and Object Store →
|
||||||
|
; Users. It can mint credentials for the bucket, and weed leaves auth off
|
||||||
|
; entirely when WEED_ADMIN_PASSWORD is empty — so an empty password means no
|
||||||
|
; panel at all rather than an open one. -dataDir persists the session key and
|
||||||
|
; the maintenance-task settings.
|
||||||
|
[program:seaweedfs-admin]
|
||||||
|
user=app
|
||||||
|
command=/bin/sh -c 'if [ -z "$WEED_ADMIN_PASSWORD" ]; then echo "seaweedfs-admin: WEED_ADMIN_PASSWORD is empty; refusing to serve an unauthenticated panel" >&2; exit 1; fi; until wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null 2>&1; do echo "waiting for seaweedfs master..."; sleep 1; done; exec /usr/local/bin/weed admin -port=23646 -master=127.0.0.1:9333 -dataDir=/seaweed/admin -metricsPort=9328'
|
||||||
|
priority=5
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; PocketBase: wait for the S3 gateway — it is the process that reads and writes
|
||||||
|
; the objects, so the bucket has to be serving before it does, exactly as the
|
||||||
|
; compose split gates the whole container on the gateway's health. Then upsert
|
||||||
|
; the superuser (idempotent) and serve. Runs as the unprivileged app user, which
|
||||||
|
; owns /pb and the pb_data volume.
|
||||||
|
[program:pocketbase]
|
||||||
|
directory=/pb
|
||||||
|
user=app
|
||||||
|
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8333/healthz >/dev/null 2>&1; do echo "waiting for seaweedfs s3..."; sleep 1; done; /pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8070'
|
||||||
|
priority=10
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; API Server: wait for PocketBase to be healthy, then start. Its bootstrap
|
||||||
|
; points PocketBase's file storage at the bucket and asks it to prove the
|
||||||
|
; gateway is reachable. It keeps no state on disk — plugin settings, like
|
||||||
|
; everything else it owns, live in PocketBase — so its working directory is
|
||||||
|
; just a place to run from.
|
||||||
|
[program:api-server]
|
||||||
|
directory=/app
|
||||||
|
user=app
|
||||||
|
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server'
|
||||||
|
priority=20
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
; nginx stays root so it can bind :80; its own workers drop to the nginx user.
|
||||||
|
[program:nginx]
|
||||||
|
command=/usr/sbin/nginx -g 'daemon off;'
|
||||||
|
priority=30
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
SUPERVISOR
|
||||||
|
|
||||||
|
# The entrypoint stays root only long enough to make the data volumes writable
|
||||||
|
# by the app user, then hands off to supervisord. The chown matters for a host
|
||||||
|
# bind mount, which arrives owned by root rather than inheriting the image's
|
||||||
|
# owner.
|
||||||
|
RUN cat > /entrypoint.sh <<'ENTRY'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
for dir in /pb/pb_data /seaweed/data /seaweed/admin; do
|
||||||
|
mkdir -p "$dir"
|
||||||
|
if [ "$(stat -c %U "$dir" 2>/dev/null)" != "app" ]; then
|
||||||
|
echo "entrypoint: taking ownership of $dir"
|
||||||
|
chown -R app:app "$dir"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exec supervisord -c /etc/supervisord.conf
|
||||||
|
ENTRY
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# API Server config: everything is local to this container. WEBAPP_URL is what
|
||||||
|
# the panel status page probes; nginx serves the Web App on :80 in here, so the
|
||||||
|
# stock default of localhost:8090 would always report the Web App as down.
|
||||||
|
#
|
||||||
|
# File storage is on by construction: the gateway is in this image, at a fixed
|
||||||
|
# loopback address, path style because a self-hosted gateway has no per-bucket
|
||||||
|
# DNS. The region is a formality SeaweedFS ignores and PocketBase insists on.
|
||||||
|
# supervisord passes these through to the API Server, whose bootstrap writes
|
||||||
|
# them into PocketBase's settings on every boot. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos; the database and PocketBase's own backups
|
||||||
|
# stay on /pb/pb_data.
|
||||||
|
ENV API_ADDR=:8080 \
|
||||||
|
POCKETBASE_URL=http://127.0.0.1:8070 \
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090 \
|
||||||
|
AUTH_USERS_COLLECTION=users \
|
||||||
|
WEBAPP_URL=http://127.0.0.1:80 \
|
||||||
|
PB_S3_ENABLED=true \
|
||||||
|
PB_S3_ENDPOINT=http://127.0.0.1:8333 \
|
||||||
|
PB_S3_BUCKET=drivervault \
|
||||||
|
PB_S3_REGION=us-east-1 \
|
||||||
|
PB_S3_FORCE_PATH_STYLE=true \
|
||||||
|
WEED_ADMIN_USER=admin
|
||||||
|
|
||||||
|
# Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD;
|
||||||
|
# PB_S3_ACCESS_KEY, PB_S3_SECRET (the identity seeded into SeaweedFS AND what
|
||||||
|
# PocketBase authenticates with — the gateway refuses to start without them);
|
||||||
|
# WEED_ADMIN_PASSWORD (the admin UI refuses to start without it).
|
||||||
|
# Optional: DRIVERVAULT_SUPERADMIN_EMAIL / DRIVERVAULT_SUPERADMIN_PASSWORD create
|
||||||
|
# the first app super-admin on boot; WEED_ADMIN_READONLY_USER / _PASSWORD add a
|
||||||
|
# view-only login to the admin UI. PB_BOOTSTRAP=false skips schema setup —
|
||||||
|
# leave it on: a release can add collections or fields the server needs, and a
|
||||||
|
# stack that skips the bootstrap never gets them. (app_settings, which holds the
|
||||||
|
# plugin settings, is created on demand; nothing else is.)
|
||||||
|
# For Anker Solix charger control, OCPP_REQUIRE_TLS (default true) rejects
|
||||||
|
# chargers that did not arrive over TLS — this image serves plain HTTP, so put a
|
||||||
|
# TLS-terminating proxy in front and set OCPP_PUBLIC_URL to the public wss://
|
||||||
|
# base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||||
|
# Pass them with `docker run -e ...`.
|
||||||
|
|
||||||
|
# Three volumes. /pb/pb_data: the database, the uploads made before S3 was on,
|
||||||
|
# PocketBase's own backups, and the server settings — the API Server keeps no
|
||||||
|
# state on disk. /seaweed/data: master, volume and filer share it, in exactly
|
||||||
|
# the layout `weed server -dir` writes (master raft state, volume .dat/.idx, the
|
||||||
|
# filer's filerldb2/ — no filename overlap), so a SEAWEED_DATA volume from
|
||||||
|
# either compose SeaweedFS file mounts here unchanged, and vice versa.
|
||||||
|
# /seaweed/admin: the admin UI's session key and maintenance-task state, small
|
||||||
|
# and no part of the object store. All pre-created and owned by app so a fresh
|
||||||
|
# named volume inherits that ownership.
|
||||||
|
RUN mkdir -p /pb/pb_data /seaweed/data /seaweed/admin /app \
|
||||||
|
&& chown -R app:app /pb /seaweed /app
|
||||||
|
VOLUME ["/pb/pb_data", "/seaweed/data", "/seaweed/admin"]
|
||||||
|
# 80 = Web App, 8070 = PocketBase admin, 8080 = API Server + embedded API panel
|
||||||
|
# (also the /ocpp/{serial} endpoint chargers dial into), 8333 = S3 gateway (for
|
||||||
|
# aws-cli and the like; loopback is enough), 23646 = SeaweedFS admin UI.
|
||||||
|
EXPOSE 80 8070 8080 8333 23646
|
||||||
|
|
||||||
|
# Every process must answer, so a wedged component shows up in `docker ps`
|
||||||
|
# instead of a container that looks up while part of it is dead. start-period
|
||||||
|
# covers the SeaweedFS chain plus the first-boot schema bootstrap on a cold
|
||||||
|
# database.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null 2>&1 \
|
||||||
|
&& wget -qO- http://127.0.0.1:8081/healthz >/dev/null 2>&1 \
|
||||||
|
&& wget -qO- http://127.0.0.1:8888/healthz >/dev/null 2>&1 \
|
||||||
|
&& wget -qO- http://127.0.0.1:8333/healthz >/dev/null 2>&1 \
|
||||||
|
&& wget -qO- http://127.0.0.1:23646/health >/dev/null 2>&1 \
|
||||||
|
&& wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1 \
|
||||||
|
&& wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 \
|
||||||
|
&& wget -qO- http://127.0.0.1:80/healthz >/dev/null 2>&1 \
|
||||||
|
|| exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -15,6 +15,8 @@ scale, upgrade or restart the pieces independently.
|
|||||||
| File | Use |
|
| File | Use |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Dockerfile` | the all-in-one image (build context must be the **repo root**) |
|
| `Dockerfile` | the all-in-one image (build context must be the **repo root**) |
|
||||||
|
| `Dockerfile.seaweedfs` | the same, with SeaweedFS (`weed server -s3`) baked in — see below |
|
||||||
|
| `Dockerfile.seaweedfs.split` | the same, with SeaweedFS split into its roles baked in — see below |
|
||||||
| `docker-compose.yml` | **builds from source** — for development and local testing |
|
| `docker-compose.yml` | **builds from source** — for development and local testing |
|
||||||
| `docker-compose.prod.yml` | **pulls the prebuilt image** from the registry |
|
| `docker-compose.prod.yml` | **pulls the prebuilt image** from the registry |
|
||||||
| `.env.example` / `.env.prod.example` | copy to `.env` for the matching compose file |
|
| `.env.example` / `.env.prod.example` | copy to `.env` for the matching compose file |
|
||||||
@@ -88,6 +90,126 @@ volume; set `PB_DATA` to an absolute host path in the prod file for a bind mount
|
|||||||
> container. Set the corresponding environment variables to change them
|
> container. Set the corresponding environment variables to change them
|
||||||
> permanently.
|
> permanently.
|
||||||
|
|
||||||
|
## File storage (SeaweedFS / S3)
|
||||||
|
|
||||||
|
Uploaded files — document scans, service and refill receipts, workshop invoices,
|
||||||
|
part photos — live inside `pb_data` by default, next to the database. Two further
|
||||||
|
compose files put them in an S3 bucket instead, so the blobs and the database can
|
||||||
|
be sized, backed up and moved independently. Nothing else changes: an attachment has
|
||||||
|
always been fetched through the API Server (`GET /api/service-records/{id}/file`),
|
||||||
|
never from a storage URL, so the Web App, the phone app and the Home Assistant
|
||||||
|
plugin cannot tell the difference.
|
||||||
|
|
||||||
|
Each shape is one self-contained compose file — nothing to layer, nothing to
|
||||||
|
remember — with an `.env` example of the same name:
|
||||||
|
|
||||||
|
| Shape | From the registry | From source |
|
||||||
|
|---|---|---|
|
||||||
|
| **Local storage** — the default, unchanged | `docker-compose.prod.yml` | `docker-compose.yml` |
|
||||||
|
| **SeaweedFS beside the image** | `docker-compose.prod.seaweedfs.yml` (builds `Dockerfile.seaweedfs`) | `docker-compose.seaweedfs.yml` |
|
||||||
|
| **SeaweedFS, split into its roles** | `docker-compose.prod.seaweedfs.split.yml` (builds `Dockerfile.seaweedfs.split`) | `docker-compose.seaweedfs.split.yml` |
|
||||||
|
| **An S3 endpoint elsewhere** | `docker-compose.prod.s3.yml` | `docker-compose.s3.yml` |
|
||||||
|
|
||||||
|
So `docker-compose.prod.seaweedfs.yml` is configured from
|
||||||
|
`.env.prod.seaweedfs.example`, `docker-compose.s3.yml` from `.env.s3.example`,
|
||||||
|
and so on:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.prod.seaweedfs.example .env # then edit it — PB_S3_* have no defaults
|
||||||
|
docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||||
|
docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `PB_S3_ACCESS_KEY` and `PB_S3_SECRET` first — both storage files refuse to
|
||||||
|
start without them. The from-source SeaweedFS files run the gateway as a
|
||||||
|
**second container** (master, volume, filer and S3 in one process, on its own
|
||||||
|
`seaweed_data` volume) plus a one-shot `seaweedfs-init` that creates the bucket,
|
||||||
|
because PocketBase never issues a `CreateBucket` of its own. The prod SeaweedFS
|
||||||
|
files go the other way and **build it into the image**: `Dockerfile.seaweedfs`
|
||||||
|
runs `weed server -s3` as a fourth supervisord program (the `weed` binary is
|
||||||
|
copied from the official image, pinned by the `SEAWEED_VERSION` build arg), on
|
||||||
|
a `/seaweed/data` volume of its own, with PocketBase's program creating the
|
||||||
|
bucket before it serves. One container, two volumes; the cost is that a new
|
||||||
|
SeaweedFS is a rebuild rather than a tag change. Inside, the volume server sits
|
||||||
|
on **8081** since 8080 is the API Server, and only the gateway is published, on
|
||||||
|
loopback by default. The build needs the repo checkout, so either build on the
|
||||||
|
deploy host or `docker compose push` the image to `AIO_IMAGE` and `pull` it
|
||||||
|
there. The external-S3 ones add no containers at all: set `PB_S3_ENDPOINT`, and
|
||||||
|
create the bucket yourself.
|
||||||
|
|
||||||
|
### Split SeaweedFS
|
||||||
|
|
||||||
|
`weed server -s3` runs master, volume, filer and gateway as four goroutines in
|
||||||
|
one process. `docker-compose.seaweedfs.split.yml` runs them as four containers
|
||||||
|
beside the all-in-one, plus a fifth: the SeaweedFS **admin UI** on port 23646,
|
||||||
|
where the cluster can be inspected and — under *Object Store → Users* — further
|
||||||
|
S3 identities minted and revoked. Split also gets you per-role restarts and
|
||||||
|
upgrades, per-role Prometheus metrics, and room to add a second volume server
|
||||||
|
later.
|
||||||
|
|
||||||
|
The prod twin, like its non-split sibling, builds it in:
|
||||||
|
`docker-compose.prod.seaweedfs.split.yml` **builds `Dockerfile.seaweedfs.split`**,
|
||||||
|
which bakes all five roles into the all-in-one image as five more supervisord
|
||||||
|
programs. One container, three volumes (`PB_DATA`, `SEAWEED_DATA`,
|
||||||
|
`SEAWEED_ADMIN_DATA`), and the bucket-and-identity seed runs inside the
|
||||||
|
gateway's program instead of a `seaweedfs-init` container. What survives of
|
||||||
|
"split" in there is per-role restart, per-role metrics ports and the admin UI;
|
||||||
|
what does not is spreading the roles over hosts. Inside, every role listens on
|
||||||
|
loopback, and only the gateway and the admin UI are published, on loopback by
|
||||||
|
default.
|
||||||
|
|
||||||
|
Identities work differently there, and it matters. SeaweedFS reads credentials
|
||||||
|
from, in descending priority: an `-s3.config` file, the filer's IAM store, then
|
||||||
|
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — and a higher source *replaces* a
|
||||||
|
lower one rather than adding to it. The single-gateway files use the env vars,
|
||||||
|
which is why nothing else may write identities there: the first user added in a
|
||||||
|
panel would displace PocketBase's key. So in the split files `seaweedfs-init`
|
||||||
|
seeds PocketBase's identity into the filer's store instead — the same store the
|
||||||
|
admin UI writes — and the gateway runs with no config file at all. One source of
|
||||||
|
truth, PocketBase's key visible in the panel beside every other, and new keys
|
||||||
|
picked up without a restart. Rotating `PB_S3_SECRET` in `.env` and restarting
|
||||||
|
updates that identity in place.
|
||||||
|
|
||||||
|
Set `SEAWEED_ADMIN_PASSWORD`: `weed admin` serves the panel with no
|
||||||
|
authentication when it is empty, and a panel that can mint bucket credentials is
|
||||||
|
the bucket (the baked-in image refuses to start the panel at all without one).
|
||||||
|
In the prod file it is bound to loopback like `SEAWEED_S3_BIND` — it is storage
|
||||||
|
plumbing, not one of the app's own panels — so a remote host needs
|
||||||
|
`SEAWEED_ADMIN_BIND=0.0.0.0` behind a reverse proxy. Neither file publishes
|
||||||
|
anything for master, volume and filer: the volume server serves file content by
|
||||||
|
id with no authentication of any kind, and the admin UI already shows what those
|
||||||
|
ports would.
|
||||||
|
|
||||||
|
Switching between any of the SeaweedFS files needs no migration: master, volume
|
||||||
|
and filer share one data mount (`/data` in the side containers, `/seaweed/data`
|
||||||
|
in the baked-in image), which is exactly the layout `weed server -dir` writes.
|
||||||
|
|
||||||
|
On every boot the API Server's bootstrap writes PocketBase's *Files storage*
|
||||||
|
settings from those variables, then asks PocketBase to prove it can reach the
|
||||||
|
bucket. Watch for it in the log:
|
||||||
|
|
||||||
|
```
|
||||||
|
[api] bootstrap: ✓ file storage → S3 (drivervault at http://seaweedfs:8333)
|
||||||
|
[api] bootstrap: ✓ S3 storage reachable
|
||||||
|
```
|
||||||
|
|
||||||
|
A boot that finds the settings already correct logs `• file storage already on S3`
|
||||||
|
and writes nothing.
|
||||||
|
|
||||||
|
Two things to know before turning it on:
|
||||||
|
|
||||||
|
- **Existing files are not migrated.** PocketBase copies nothing when the setting
|
||||||
|
flips, so attachments uploaded before the switch stop resolving. Copy
|
||||||
|
`pb_data/storage/<collectionId>/<recordId>/<file>` into the bucket root, keeping
|
||||||
|
that layout, *before* enabling it — or start from a stack with no attachments.
|
||||||
|
- **Going back to the plain compose file is not an off switch.** It leaves
|
||||||
|
PocketBase pointed at
|
||||||
|
the bucket, deliberately: files already written there are reachable only while
|
||||||
|
it is. Move them back and turn it off in PocketBase's own admin UI. For the same
|
||||||
|
reason a rotation of `PB_S3_SECRET` alone is invisible to the bootstrap —
|
||||||
|
PocketBase masks the stored secret on read — so change another `PB_S3_*` value
|
||||||
|
alongside it, or set it in the admin UI.
|
||||||
|
|
||||||
## Charger control (OCPP)
|
## Charger control (OCPP)
|
||||||
|
|
||||||
Chargers in own/proxy mode dial in to `/ocpp/{serial}` on the **API Server port
|
Chargers in own/proxy mode dial in to `/ocpp/{serial}` on the **API Server port
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
name: drivervault-aio
|
||||||
|
|
||||||
|
# Production all-in-one, with external S3 — pulls the prebuilt image from the
|
||||||
|
# registry instead of building. Self-contained: one file, no overlays.
|
||||||
|
# Everything an operator needs to set lives in .env.
|
||||||
|
#
|
||||||
|
# 1. cp .env.prod.s3.example .env (then edit it — PB_S3_* especially)
|
||||||
|
# 2. docker compose -f docker-compose.prod.s3.yml pull
|
||||||
|
# 3. docker compose -f docker-compose.prod.s3.yml up -d
|
||||||
|
#
|
||||||
|
# This is docker-compose.prod.yml pointed at an S3 endpoint that already exists
|
||||||
|
# somewhere else — its own host, another compose project, or any S3-compatible
|
||||||
|
# service. PocketBase keeps its record files — document scans, service and
|
||||||
|
# refill receipts, workshop invoices, part photos — in that bucket instead of on
|
||||||
|
# the pb_data volume. The database and PocketBase's own backups stay on PB_DATA.
|
||||||
|
# Clients cannot tell the difference: an attachment has always been fetched
|
||||||
|
# through the API Server, never from a storage URL.
|
||||||
|
#
|
||||||
|
# The bucket must already exist, and nothing here runs the gateway. For a
|
||||||
|
# SeaweedFS that comes up with the container, use
|
||||||
|
# docker-compose.prod.seaweedfs.yml.
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
#
|
||||||
|
# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API
|
||||||
|
# Server creates any missing collections and the DriverVault super-admin from
|
||||||
|
# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent.
|
||||||
|
|
||||||
|
services:
|
||||||
|
drivervault:
|
||||||
|
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}"
|
||||||
|
container_name: drivervault-aio
|
||||||
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||||
|
# Harmless when the endpoint is somewhere else entirely.
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
environment:
|
||||||
|
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||||
|
# inside this container, so plain localhost:8090 would never answer.
|
||||||
|
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||||
|
# screen permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||||
|
# add collections or fields the server needs, and a stack that skips the
|
||||||
|
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||||
|
# app_settings, the collection holding the plugin settings, which it
|
||||||
|
# creates on demand because it cannot serve the plugin panel without it.
|
||||||
|
# Every other schema change still depends on this flag. Turn it off only
|
||||||
|
# for a database you know already matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||||
|
# charger can only connect when TLS is terminated in front of it (set
|
||||||
|
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||||
|
# with OCPP_REQUIRE_TLS=false.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# supervisord passes these through to the API Server, whose bootstrap
|
||||||
|
# writes them into PocketBase's
|
||||||
|
# settings on every boot, idempotently. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos. The database and PocketBase's own
|
||||||
|
# backups stay on PB_DATA. The bucket must already exist: nothing here
|
||||||
|
# creates it.
|
||||||
|
PB_S3_ENABLED: "true"
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
|
||||||
|
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||||
|
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8090}:80" # Web App
|
||||||
|
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||||
|
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||||
|
volumes:
|
||||||
|
# The only volume — named by default; set PB_DATA to a host path in .env
|
||||||
|
# for a bind mount. The API Server keeps no state on disk, so everything
|
||||||
|
# it owns (plugin settings included) is in here.
|
||||||
|
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||||
|
healthcheck:
|
||||||
|
# All three processes must answer. Declared here as well as in the image so
|
||||||
|
# the check is visible, and works against an older pulled image.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
name: drivervault-aio
|
||||||
|
|
||||||
|
# Production all-in-one with SeaweedFS split into its roles — and, unlike the
|
||||||
|
# other prod files, built into ONE image here: Dockerfile.seaweedfs.split bakes
|
||||||
|
# master, volume, filer, S3 gateway and the admin UI into the same container as
|
||||||
|
# PocketBase, the API Server and the Web App. One service, one container, three
|
||||||
|
# volumes. Self-contained: one file, no overlays. Everything an operator needs
|
||||||
|
# to set lives in .env.
|
||||||
|
#
|
||||||
|
# 1. cp .env.prod.seaweedfs.split.example .env (then edit it)
|
||||||
|
# 2. docker compose -f docker-compose.prod.seaweedfs.split.yml build
|
||||||
|
# 3. docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
|
||||||
|
#
|
||||||
|
# The build context is the repo root, so this has to run on a host that has the
|
||||||
|
# checkout. To deploy elsewhere, `push` the built image to the registry named in
|
||||||
|
# AIO_IMAGE and `pull` it there — `image:` and `build:` are both set, so the
|
||||||
|
# same file does either.
|
||||||
|
#
|
||||||
|
# What the split buys inside one image: per-role restart under supervisord,
|
||||||
|
# per-role metrics ports, and the SeaweedFS admin UI (weed admin) — a cluster
|
||||||
|
# view, and Object Store → Users, where S3 identities are created and revoked
|
||||||
|
# without touching a file. What it costs against the six-container shape it
|
||||||
|
# replaces: a new SeaweedFS is a rebuild (SEAWEED_VERSION below), not a tag
|
||||||
|
# change, and the roles cannot be spread over hosts. If the object store should
|
||||||
|
# stay outside the app image, use docker-compose.prod.seaweedfs.yml — the S3
|
||||||
|
# behaviour is identical.
|
||||||
|
#
|
||||||
|
# The on-disk layout is the same as every other SeaweedFS shape here: master,
|
||||||
|
# volume and filer share one data mount, exactly as `weed server -dir` lays it
|
||||||
|
# out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no filename
|
||||||
|
# overlap). So a SEAWEED_DATA volume from docker-compose.prod.seaweedfs.yml
|
||||||
|
# mounts here unchanged, and vice versa, with no migration either way.
|
||||||
|
#
|
||||||
|
# Inside the container every SeaweedFS role listens on loopback except the S3
|
||||||
|
# gateway and the admin UI, and those two are published on loopback by default,
|
||||||
|
# the way the other SeaweedFS files publish them. The volume server serves file
|
||||||
|
# content by id with no authentication at all, so it is never reachable from
|
||||||
|
# outside; the admin UI shows what its port would.
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
#
|
||||||
|
# On first boot the S3 gateway's program creates the bucket and seeds
|
||||||
|
# PocketBase's identity into the filer's IAM store, PocketBase upserts the
|
||||||
|
# superuser from PB_ADMIN_*, and the API Server creates any missing collections,
|
||||||
|
# the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*, and points
|
||||||
|
# PocketBase's file storage at the bucket. Every step is idempotent.
|
||||||
|
|
||||||
|
services:
|
||||||
|
drivervault:
|
||||||
|
build:
|
||||||
|
# Project root (one level up from this compose file), so the Dockerfile
|
||||||
|
# can reach both "API Server/" and "Web App/".
|
||||||
|
context: ..
|
||||||
|
dockerfile: Docker-AIO/Dockerfile.seaweedfs.split
|
||||||
|
args:
|
||||||
|
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
|
||||||
|
- VITE_API_BASE=${VITE_API_BASE:-}
|
||||||
|
# Bare names = pass through only when set in the environment, so an
|
||||||
|
# unset PB_VERSION / SEAWEED_VERSION leaves the Dockerfile pin in place
|
||||||
|
# instead of overriding it with an empty string (which for PB_VERSION
|
||||||
|
# would resolve "latest" at build time, and for SEAWEED_VERSION would
|
||||||
|
# not build at all).
|
||||||
|
- PB_VERSION
|
||||||
|
- SEAWEED_VERSION
|
||||||
|
# Tagged for the registry so `docker compose push` lands it where `pull`
|
||||||
|
# on the deploy host expects it.
|
||||||
|
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio-seaweedfs-split:latest}"
|
||||||
|
container_name: drivervault-aio
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||||
|
# inside this container, so plain localhost:8090 would never answer.
|
||||||
|
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||||
|
# screen permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||||
|
# add collections or fields the server needs, and a stack that skips the
|
||||||
|
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||||
|
# app_settings, the collection holding the plugin settings, which it
|
||||||
|
# creates on demand because it cannot serve the plugin panel without it.
|
||||||
|
# Every other schema change still depends on this flag. Turn it off only
|
||||||
|
# for a database you know already matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||||
|
# charger can only connect when TLS is terminated in front of it (set
|
||||||
|
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||||
|
# with OCPP_REQUIRE_TLS=false.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# The image fixes the rest (PB_S3_ENABLED, the loopback endpoint, path
|
||||||
|
# style, the region): the gateway is inside, at an address that cannot
|
||||||
|
# change. supervisord passes these through to the API Server, whose
|
||||||
|
# bootstrap writes them into PocketBase's settings on every boot,
|
||||||
|
# idempotently. Only record files move — scans, receipts, invoices, part
|
||||||
|
# photos. The database and PocketBase's own backups stay on PB_DATA.
|
||||||
|
#
|
||||||
|
# The credentials do double duty: the gateway's program seeds them into
|
||||||
|
# the filer's IAM store as the identity named "drivervault" (updating it
|
||||||
|
# in place on every boot — that is how a rotated secret lands) *and* they
|
||||||
|
# are what PocketBase authenticates with. No safe defaults; the gateway
|
||||||
|
# refuses to start without them, and with it the whole container.
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
# --- SeaweedFS admin UI --------------------------------------------
|
||||||
|
# An unauthenticated panel that can mint credentials for the bucket *is*
|
||||||
|
# the bucket, so the password is required rather than defaulted — weed
|
||||||
|
# leaves auth off entirely when it is empty, and the image refuses to
|
||||||
|
# start the panel at all in that case. Read from WEED_ADMIN_* rather
|
||||||
|
# than a flag, which keeps it off the process command line.
|
||||||
|
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
|
||||||
|
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
|
||||||
|
# Optional view-only login. weed ignores it unless the admin password
|
||||||
|
# above is set, which it is.
|
||||||
|
WEED_ADMIN_READONLY_USER: "${SEAWEED_ADMIN_READONLY_USER:-}"
|
||||||
|
WEED_ADMIN_READONLY_PASSWORD: "${SEAWEED_ADMIN_READONLY_PASSWORD:-}"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8090}:80" # Web App
|
||||||
|
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||||
|
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||||
|
# The S3 gateway, loopback only: PocketBase reaches it inside the
|
||||||
|
# container, so this is here for `aws s3 ls --endpoint-url
|
||||||
|
# http://127.0.0.1:8333` and nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to
|
||||||
|
# expose it, and mean it.
|
||||||
|
- "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333"
|
||||||
|
# The admin UI, loopback for the same reason: storage plumbing, not one
|
||||||
|
# of the app's own panels. On a remote host that means unreachable, so
|
||||||
|
# set SEAWEED_ADMIN_BIND=0.0.0.0 and put it behind a reverse proxy.
|
||||||
|
- "${SEAWEED_ADMIN_BIND:-127.0.0.1}:${SEAWEED_ADMIN_PORT:-23646}:23646"
|
||||||
|
volumes:
|
||||||
|
# Named volumes by default; set any of them to a host path in .env for a
|
||||||
|
# bind mount. The API Server keeps no state on disk, so everything it
|
||||||
|
# owns (plugin settings included) is in PB_DATA. Back up PB_DATA and
|
||||||
|
# SEAWEED_DATA together — from here on the attachments live in the
|
||||||
|
# second, not the first.
|
||||||
|
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||||
|
- "${SEAWEED_DATA:-seaweed_data}:/seaweed/data"
|
||||||
|
# The admin UI's own small volume: session key and maintenance state, no
|
||||||
|
# object data.
|
||||||
|
- "${SEAWEED_ADMIN_DATA:-seaweed_admin}:/seaweed/admin"
|
||||||
|
healthcheck:
|
||||||
|
# Every process must answer — the five SeaweedFS roles and the three app
|
||||||
|
# processes. Declared here as well as in the image so the check is
|
||||||
|
# visible, and works against an older pulled image. start_period covers
|
||||||
|
# the SeaweedFS chain plus the first-boot schema bootstrap.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null && wget -qO- http://127.0.0.1:8081/healthz >/dev/null && wget -qO- http://127.0.0.1:8888/healthz >/dev/null && wget -qO- http://127.0.0.1:8333/healthz >/dev/null && wget -qO- http://127.0.0.1:23646/health >/dev/null && wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 90s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
|
# Master, volume and filer share it — the same layout `weed server -dir`
|
||||||
|
# writes, so this file and docker-compose.prod.seaweedfs.yml can swap places
|
||||||
|
# on it.
|
||||||
|
seaweed_data:
|
||||||
|
# The admin UI's own session key and maintenance-task state. Small, and no
|
||||||
|
# part of the object store.
|
||||||
|
seaweed_admin:
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
name: drivervault-aio
|
||||||
|
|
||||||
|
# Production all-in-one with SeaweedFS — and, unlike docker-compose.prod.yml,
|
||||||
|
# built into ONE image here: Dockerfile.seaweedfs bakes `weed server -s3`
|
||||||
|
# (master, volume, filer and S3 gateway in one process) into the same container
|
||||||
|
# as PocketBase, the API Server and the Web App. One service, one container,
|
||||||
|
# two volumes. Self-contained: one file, no overlays. Everything an operator
|
||||||
|
# needs to set lives in .env.
|
||||||
|
#
|
||||||
|
# 1. cp .env.prod.seaweedfs.example .env (then edit it)
|
||||||
|
# 2. docker compose -f docker-compose.prod.seaweedfs.yml build
|
||||||
|
# 3. docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||||
|
#
|
||||||
|
# The build context is the repo root, so this has to run on a host that has the
|
||||||
|
# checkout. To deploy elsewhere, `push` the built image to the registry named in
|
||||||
|
# AIO_IMAGE and `pull` it there — `image:` and `build:` are both set, so the
|
||||||
|
# same file does either.
|
||||||
|
#
|
||||||
|
# This is docker-compose.prod.yml plus an S3 object store: PocketBase keeps its
|
||||||
|
# record files — document scans, service and refill receipts, workshop invoices,
|
||||||
|
# part photos — in a SeaweedFS bucket instead of on the pb_data volume next to
|
||||||
|
# the database. The database and PocketBase's own backups stay on PB_DATA.
|
||||||
|
# Clients cannot tell the difference: an attachment has always been fetched
|
||||||
|
# through the API Server, never from a storage URL.
|
||||||
|
#
|
||||||
|
# What baking it in costs against the two-container shape it replaces: a new
|
||||||
|
# SeaweedFS is a rebuild (SEAWEED_VERSION below), not a tag change. For
|
||||||
|
# per-role restart, metrics and the SeaweedFS admin UI, see
|
||||||
|
# docker-compose.prod.seaweedfs.split.yml — the S3 behaviour is identical, and
|
||||||
|
# the data volume is interchangeable (both write the `weed server -dir` layout).
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
#
|
||||||
|
# On first boot PocketBase's program creates the bucket and upserts the
|
||||||
|
# superuser from PB_ADMIN_*, and the API Server creates any missing
|
||||||
|
# collections, the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*, and
|
||||||
|
# points PocketBase's file storage at the bucket. Every step is idempotent.
|
||||||
|
|
||||||
|
services:
|
||||||
|
drivervault:
|
||||||
|
build:
|
||||||
|
# Project root (one level up from this compose file), so the Dockerfile
|
||||||
|
# can reach both "API Server/" and "Web App/".
|
||||||
|
context: ..
|
||||||
|
dockerfile: Docker-AIO/Dockerfile.seaweedfs
|
||||||
|
args:
|
||||||
|
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
|
||||||
|
- VITE_API_BASE=${VITE_API_BASE:-}
|
||||||
|
# Bare names = pass through only when set in the environment, so an
|
||||||
|
# unset PB_VERSION / SEAWEED_VERSION leaves the Dockerfile pin in place
|
||||||
|
# instead of overriding it with an empty string (which for PB_VERSION
|
||||||
|
# would resolve "latest" at build time, and for SEAWEED_VERSION would
|
||||||
|
# not build at all).
|
||||||
|
- PB_VERSION
|
||||||
|
- SEAWEED_VERSION
|
||||||
|
# Tagged for the registry so `docker compose push` lands it where `pull`
|
||||||
|
# on the deploy host expects it.
|
||||||
|
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio-seaweedfs:latest}"
|
||||||
|
container_name: drivervault-aio
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||||
|
# inside this container, so plain localhost:8090 would never answer.
|
||||||
|
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||||
|
# screen permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||||
|
# add collections or fields the server needs, and a stack that skips the
|
||||||
|
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||||
|
# app_settings, the collection holding the plugin settings, which it
|
||||||
|
# creates on demand because it cannot serve the plugin panel without it.
|
||||||
|
# Every other schema change still depends on this flag. Turn it off only
|
||||||
|
# for a database you know already matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||||
|
# charger can only connect when TLS is terminated in front of it (set
|
||||||
|
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||||
|
# with OCPP_REQUIRE_TLS=false.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# The image fixes the rest (PB_S3_ENABLED, the loopback endpoint, path
|
||||||
|
# style, the region): the gateway is inside, at an address that cannot
|
||||||
|
# change. supervisord passes these through to the API Server, whose
|
||||||
|
# bootstrap writes them into PocketBase's settings on every boot,
|
||||||
|
# idempotently. Only record files move — scans, receipts, invoices, part
|
||||||
|
# photos. The database and PocketBase's own backups stay on PB_DATA.
|
||||||
|
#
|
||||||
|
# The credentials do double duty: they configure the gateway's single
|
||||||
|
# identity — which is what takes it out of its default allow-anyone mode
|
||||||
|
# — *and* they are what PocketBase authenticates with. No safe defaults;
|
||||||
|
# the gateway refuses to start without them, and with it the whole
|
||||||
|
# container.
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8090}:80" # Web App
|
||||||
|
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||||
|
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||||
|
# The S3 gateway, loopback only: PocketBase reaches it inside the
|
||||||
|
# container, so this is here for `aws s3 ls --endpoint-url
|
||||||
|
# http://127.0.0.1:8333` and nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to
|
||||||
|
# expose it, and mean it. Nothing else of SeaweedFS is published: the
|
||||||
|
# volume server (8081 inside) serves file content by id with no
|
||||||
|
# authentication at all.
|
||||||
|
- "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333"
|
||||||
|
volumes:
|
||||||
|
# Named volumes by default; set either to a host path in .env for a bind
|
||||||
|
# mount. The API Server keeps no state on disk, so everything it owns
|
||||||
|
# (plugin settings included) is in PB_DATA. Back up PB_DATA and
|
||||||
|
# SEAWEED_DATA together — from here on the attachments live in the
|
||||||
|
# second, not the first.
|
||||||
|
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||||
|
- "${SEAWEED_DATA:-seaweed_data}:/seaweed/data"
|
||||||
|
healthcheck:
|
||||||
|
# Every process must answer — SeaweedFS's master and gateway and the three
|
||||||
|
# app processes. Declared here as well as in the image so the check is
|
||||||
|
# visible, and works against an older pulled image. start_period covers
|
||||||
|
# SeaweedFS coming up plus the first-boot schema bootstrap.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null && wget -qO- http://127.0.0.1:8333/healthz >/dev/null && wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 90s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
|
# The `weed server -dir` layout, so this file and its .split. twin can swap
|
||||||
|
# places on it.
|
||||||
|
seaweed_data:
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
name: drivervault-aio
|
||||||
|
|
||||||
|
# Single all-in-one container, with external S3: PocketBase + API Server + Web
|
||||||
|
# App (nginx) in one image, storing files in an S3 endpoint that already exists
|
||||||
|
# somewhere else. Self-contained — one file, nothing to layer.
|
||||||
|
#
|
||||||
|
# cp .env.s3.example .env (then edit it — PB_S3_* especially)
|
||||||
|
# docker compose -f docker-compose.s3.yml up -d --build
|
||||||
|
#
|
||||||
|
# The build context is the project root so the Dockerfile can reach both
|
||||||
|
# "API Server/" and "Web App/".
|
||||||
|
#
|
||||||
|
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
|
||||||
|
# document scans, service and refill receipts, workshop invoices, part photos —
|
||||||
|
# in that bucket instead of on the pb_data volume next to the database. The
|
||||||
|
# database and PocketBase's own backups stay on pb_data. Clients cannot tell the
|
||||||
|
# difference: an attachment has always been fetched through the API Server,
|
||||||
|
# never from a storage URL.
|
||||||
|
#
|
||||||
|
# The bucket must already exist, and nothing here runs the gateway. For a
|
||||||
|
# SeaweedFS that comes up with the container, use docker-compose.seaweedfs.yml.
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
|
||||||
|
services:
|
||||||
|
drivervault:
|
||||||
|
build:
|
||||||
|
# Project root (one level up from this compose file).
|
||||||
|
context: ..
|
||||||
|
dockerfile: Docker-AIO/Dockerfile
|
||||||
|
args:
|
||||||
|
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
|
||||||
|
- VITE_API_BASE=${VITE_API_BASE:-}
|
||||||
|
# Bare name = pass through only when set in the environment, so an unset
|
||||||
|
# PB_VERSION leaves the Dockerfile pin in place instead of overriding it
|
||||||
|
# with an empty string (which would resolve "latest" at build time).
|
||||||
|
- PB_VERSION
|
||||||
|
image: drivervault-aio
|
||||||
|
container_name: drivervault-aio
|
||||||
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||||
|
# Harmless when the endpoint is somewhere else entirely.
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
environment:
|
||||||
|
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||||
|
# inside this container, so plain localhost:8090 would never answer.
|
||||||
|
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||||
|
# screen permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||||
|
# add collections or fields the server needs, and a stack that skips the
|
||||||
|
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||||
|
# app_settings, the collection holding the plugin settings, which it
|
||||||
|
# creates on demand because it cannot serve the plugin panel without it.
|
||||||
|
# Every other schema change still depends on this flag. Turn it off only
|
||||||
|
# for a database you know already matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||||
|
# charger can only connect when TLS is terminated in front of it (set
|
||||||
|
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||||
|
# with OCPP_REQUIRE_TLS=false.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# supervisord passes these through to the API Server, whose bootstrap
|
||||||
|
# writes them into PocketBase's
|
||||||
|
# settings on every boot, idempotently. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos. The database and PocketBase's own
|
||||||
|
# backups stay on pb_data. The bucket must already exist: nothing here
|
||||||
|
# creates it.
|
||||||
|
PB_S3_ENABLED: "true"
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
|
||||||
|
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||||
|
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8090}:80" # Web App
|
||||||
|
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||||
|
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||||
|
volumes:
|
||||||
|
# The only volume: the API Server keeps no state on disk, so everything
|
||||||
|
# it owns — plugin settings included — lives in the database.
|
||||||
|
- pb_data:/pb/pb_data
|
||||||
|
healthcheck:
|
||||||
|
# All three processes must answer. Declared here as well as in the image so
|
||||||
|
# the check is visible, and works against an older pulled image.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
name: drivervault-aio
|
||||||
|
|
||||||
|
# Single all-in-one container, with SeaweedFS split into its four roles:
|
||||||
|
# PocketBase + API Server + Web App (nginx) in one image, beside master, volume,
|
||||||
|
# filer, S3 gateway and the SeaweedFS admin UI as separate containers.
|
||||||
|
# Self-contained — one file, nothing to layer.
|
||||||
|
#
|
||||||
|
# cp .env.seaweedfs.split.example .env (then edit it)
|
||||||
|
# docker compose -f docker-compose.seaweedfs.split.yml up -d --build
|
||||||
|
#
|
||||||
|
# The build context is the project root so the Dockerfile can reach both
|
||||||
|
# "API Server/" and "Web App/".
|
||||||
|
#
|
||||||
|
# This is docker-compose.seaweedfs.yml with the storage layer taken apart.
|
||||||
|
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
|
||||||
|
# process; here each is its own container. What that buys:
|
||||||
|
#
|
||||||
|
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
|
||||||
|
# where S3 identities are created and revoked without touching a file;
|
||||||
|
# • per-role restart, upgrade and Prometheus metrics;
|
||||||
|
# • room to add a second volume server later, on this host or another.
|
||||||
|
#
|
||||||
|
# What it costs: five containers beside the all-in-one instead of one, and five
|
||||||
|
# healthchecks to keep the boot order honest. If none of the above is wanted,
|
||||||
|
# use docker-compose.seaweedfs.yml — the S3 behaviour is identical.
|
||||||
|
#
|
||||||
|
# None of them run inside the all-in-one image, for the same reason the single
|
||||||
|
# gateway does not: keeping the object store in that image, on the volume the
|
||||||
|
# files are being moved off, would defeat the point and would mean rebuilding.
|
||||||
|
#
|
||||||
|
# The on-disk layout is deliberately the same as the single-process file's:
|
||||||
|
# master, volume and filer share one /data mount, exactly as `weed server -dir`
|
||||||
|
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
|
||||||
|
# filename overlap). So the two files are interchangeable on the same volume,
|
||||||
|
# with no migration either way. A *second* volume server would need its own.
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
|
||||||
|
services:
|
||||||
|
# --- SeaweedFS: master -----------------------------------------------------
|
||||||
|
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
|
||||||
|
# the other roles are told to reach it by, so it must be the service name and
|
||||||
|
# not the container IP the process would otherwise detect.
|
||||||
|
seaweedfs-master:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-aio-seaweedfs-master
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
|
||||||
|
-volumeSizeLimitMB=1024 -metricsPort=9324
|
||||||
|
volumes:
|
||||||
|
- seaweed_data:/data
|
||||||
|
ports:
|
||||||
|
# Master UI / API. Useful while developing; the admin UI below covers the
|
||||||
|
# same ground with a nicer face.
|
||||||
|
- "${SEAWEED_MASTER_PORT:-9333}:9333"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: volume server ---------------------------------------------
|
||||||
|
# Where the bytes actually land. -max=0 lets it size itself from free disk
|
||||||
|
# rather than the default cap of 8 volumes.
|
||||||
|
seaweedfs-volume:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-aio-seaweedfs-volume
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
|
||||||
|
-port=8080 -dir=/data -max=0 -metricsPort=9325
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-master:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- seaweed_data:/data
|
||||||
|
ports:
|
||||||
|
# This port serves file content by file id with NO authentication — the S3
|
||||||
|
# credentials do not apply to it. Publish it only where you would be
|
||||||
|
# willing to publish the bucket itself.
|
||||||
|
- "${SEAWEED_VOLUME_PORT:-8081}:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: filer ------------------------------------------------------
|
||||||
|
# Gives the flat volume store a directory tree — buckets, object keys — and
|
||||||
|
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
|
||||||
|
# embedded leveldb goes; without it that would be the container's working
|
||||||
|
# directory, and the identities would not survive a recreate.
|
||||||
|
seaweedfs-filer:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-aio-seaweedfs-filer
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
|
||||||
|
-port=8888 -defaultStoreDir=/data -metricsPort=9326
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-volume:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- seaweed_data:/data
|
||||||
|
ports:
|
||||||
|
- "${SEAWEED_FILER_PORT:-8888}:8888"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: bucket and identity seed ----------------------------------
|
||||||
|
# Runs once and exits, before the gateway starts. Two jobs:
|
||||||
|
#
|
||||||
|
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
|
||||||
|
# 2. write PocketBase's S3 identity into the filer's IAM store.
|
||||||
|
#
|
||||||
|
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
|
||||||
|
# docker-compose.seaweedfs.yml does. Those env vars are the *lowest* priority
|
||||||
|
# credential source in SeaweedFS: they are read only while the filer's store is
|
||||||
|
# empty, so the first identity added in the admin UI would silently displace
|
||||||
|
# them and lock PocketBase out. Seeding the store the admin UI itself writes
|
||||||
|
# leaves one source of truth, and the key PocketBase uses appears under
|
||||||
|
# Object Store → Users like any other.
|
||||||
|
#
|
||||||
|
# Both commands update in place, so every later boot re-applies the values from
|
||||||
|
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
|
||||||
|
# reaches the gateway.
|
||||||
|
#
|
||||||
|
# The closing grep is the gate: an empty IAM store means the gateway would come
|
||||||
|
# up in its allow-anyone default, so this fails loudly instead and the gateway
|
||||||
|
# below never starts. No `|| true` here, deliberately.
|
||||||
|
seaweedfs-init:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-aio-seaweedfs-init
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-filer:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# Passed as env and expanded by the shell inside the container, so the
|
||||||
|
# secret stays out of the container's argv.
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
printf '%s\n' \
|
||||||
|
"s3.bucket.create -name $$PB_S3_BUCKET" \
|
||||||
|
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
|
||||||
|
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
|
||||||
|
echo "s3.configure" \
|
||||||
|
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
|
||||||
|
| grep -q "$$PB_S3_ACCESS_KEY"
|
||||||
|
|
||||||
|
# --- SeaweedFS: S3 gateway -------------------------------------------------
|
||||||
|
# The endpoint PocketBase talks to. No -config file: with only -filer given,
|
||||||
|
# credentials come from the filer's IAM store, which is what lets the admin UI
|
||||||
|
# add and revoke identities without a restart. A config file would take
|
||||||
|
# priority over that store and make the admin UI's users inert.
|
||||||
|
seaweedfs-s3:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-aio-seaweedfs-s3
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
|
||||||
|
-metricsPort=9327
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-filer:
|
||||||
|
condition: service_healthy
|
||||||
|
# Never serve before an identity exists — see seaweedfs-init above.
|
||||||
|
seaweedfs-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
ports:
|
||||||
|
# The stack reaches the gateway over the compose network; this is here so
|
||||||
|
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
|
||||||
|
- "${SEAWEED_S3_PORT:-8333}:8333"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8333/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: admin UI ---------------------------------------------------
|
||||||
|
# http://localhost:23646 — cluster topology, volumes, buckets, maintenance
|
||||||
|
# tasks, and Object Store → Users, where S3 access keys are minted and revoked.
|
||||||
|
# It finds the filer through the master, so -master is all it needs.
|
||||||
|
#
|
||||||
|
# An unauthenticated panel that can mint credentials for the bucket *is* the
|
||||||
|
# bucket, so the password is required rather than defaulted — weed leaves auth
|
||||||
|
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
|
||||||
|
# flag, which keeps it off the process command line. -dataDir persists the
|
||||||
|
# session key and the maintenance-task settings.
|
||||||
|
seaweedfs-admin:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-aio-seaweedfs-admin
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
|
||||||
|
-metricsPort=9328
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-master:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
|
||||||
|
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
|
||||||
|
volumes:
|
||||||
|
- seaweed_admin:/data
|
||||||
|
ports:
|
||||||
|
- "${SEAWEED_ADMIN_PORT:-23646}:23646"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
drivervault:
|
||||||
|
build:
|
||||||
|
# Project root (one level up from this compose file).
|
||||||
|
context: ..
|
||||||
|
dockerfile: Docker-AIO/Dockerfile
|
||||||
|
args:
|
||||||
|
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
|
||||||
|
- VITE_API_BASE=${VITE_API_BASE:-}
|
||||||
|
# Bare name = pass through only when set in the environment, so an unset
|
||||||
|
# PB_VERSION leaves the Dockerfile pin in place instead of overriding it
|
||||||
|
# with an empty string (which would resolve "latest" at build time).
|
||||||
|
- PB_VERSION
|
||||||
|
image: drivervault-aio
|
||||||
|
container_name: drivervault-aio
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# PocketBase — inside this container — is the process that reads and
|
||||||
|
# writes the objects, so the gateway has to be serving first, and the
|
||||||
|
# bucket has to exist before the bootstrap points PocketBase at it.
|
||||||
|
seaweedfs-s3:
|
||||||
|
condition: service_healthy
|
||||||
|
seaweedfs-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||||
|
# inside this container, so plain localhost:8090 would never answer.
|
||||||
|
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||||
|
# screen permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||||
|
# add collections or fields the server needs, and a stack that skips the
|
||||||
|
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||||
|
# app_settings, the collection holding the plugin settings, which it
|
||||||
|
# creates on demand because it cannot serve the plugin panel without it.
|
||||||
|
# Every other schema change still depends on this flag. Turn it off only
|
||||||
|
# for a database you know already matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||||
|
# charger can only connect when TLS is terminated in front of it (set
|
||||||
|
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||||
|
# with OCPP_REQUIRE_TLS=false.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# supervisord passes these through to the API Server, whose bootstrap
|
||||||
|
# writes them into PocketBase's
|
||||||
|
# settings on every boot, idempotently. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos. The database and PocketBase's own
|
||||||
|
# backups stay on pb_data.
|
||||||
|
PB_S3_ENABLED: "true"
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
# The gateway's service name: a server-to-server call inside the compose
|
||||||
|
# network.
|
||||||
|
PB_S3_ENDPOINT: "http://seaweedfs-s3:8333"
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||||
|
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||||
|
PB_S3_FORCE_PATH_STYLE: "true"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8090}:80" # Web App
|
||||||
|
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||||
|
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||||
|
volumes:
|
||||||
|
# The only volume: the API Server keeps no state on disk, so everything
|
||||||
|
# it owns — plugin settings included — lives in the database.
|
||||||
|
- pb_data:/pb/pb_data
|
||||||
|
healthcheck:
|
||||||
|
# All three processes must answer. Declared here as well as in the image so
|
||||||
|
# the check is visible, and works against an older pulled image.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
|
# Shared by master, volume and filer — the same layout `weed server -dir`
|
||||||
|
# writes, so this file and docker-compose.seaweedfs.yml can swap places on it.
|
||||||
|
seaweed_data:
|
||||||
|
# The admin UI's own session key and maintenance-task state. Small, and no
|
||||||
|
# part of the object store.
|
||||||
|
seaweed_admin:
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
name: drivervault-aio
|
||||||
|
|
||||||
|
# Single all-in-one container, with SeaweedFS: PocketBase + API Server + Web App
|
||||||
|
# (nginx) in one image, plus an S3 object store beside it. Self-contained — one
|
||||||
|
# file, nothing to layer.
|
||||||
|
#
|
||||||
|
# cp .env.seaweedfs.example .env (then edit it)
|
||||||
|
# docker compose -f docker-compose.seaweedfs.yml up -d --build
|
||||||
|
#
|
||||||
|
# The build context is the project root so the Dockerfile can reach both
|
||||||
|
# "API Server/" and "Web App/".
|
||||||
|
#
|
||||||
|
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
|
||||||
|
# document scans, service and refill receipts, workshop invoices, part photos —
|
||||||
|
# in a SeaweedFS bucket instead of on the pb_data volume next to the database.
|
||||||
|
# The database and PocketBase's own backups stay on pb_data. Clients cannot tell
|
||||||
|
# the difference: an attachment has always been fetched through the API Server,
|
||||||
|
# never from a storage URL.
|
||||||
|
#
|
||||||
|
# SeaweedFS runs as a second container beside the all-in-one, not as a fourth
|
||||||
|
# process inside it: keeping the object store in that image, on the volume the
|
||||||
|
# files are being moved off, would defeat the point and would mean rebuilding.
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
|
||||||
|
services:
|
||||||
|
seaweedfs:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-aio-seaweedfs
|
||||||
|
restart: unless-stopped
|
||||||
|
# One process, four roles: master, volume, filer and the S3 gateway. -dir is
|
||||||
|
# the only state it keeps.
|
||||||
|
command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024
|
||||||
|
environment:
|
||||||
|
# SeaweedFS falls back to these when started without an -s3.config file,
|
||||||
|
# and configuring one identity is what takes the S3 gateway out of its
|
||||||
|
# default allow-anyone mode. The same credentials PocketBase authenticates
|
||||||
|
# with below — one pair to set, in .env.
|
||||||
|
AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
volumes:
|
||||||
|
# From here on the attachments live here, not on pb_data.
|
||||||
|
- seaweed_data:/data
|
||||||
|
ports:
|
||||||
|
# The stack reaches the gateway over the compose network; this is here so
|
||||||
|
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
|
||||||
|
- "${SEAWEED_S3_PORT:-8333}:8333"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
seaweedfs-init:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-aio-seaweedfs-init
|
||||||
|
# Runs once and exits. PocketBase never issues a CreateBucket of its own and
|
||||||
|
# SeaweedFS will not conjure one on first upload, so something has to.
|
||||||
|
# Creating a bucket that already exists is a no-op, so every later boot
|
||||||
|
# passes straight through.
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
seaweedfs:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
# `|| true` so a restart is never blocked by the shell's exit status: this
|
||||||
|
# step is best-effort, and a gateway that is genuinely unreachable is
|
||||||
|
# reported by the API Server's own S3 check at boot, with the reason.
|
||||||
|
command:
|
||||||
|
- 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true'
|
||||||
|
|
||||||
|
drivervault:
|
||||||
|
build:
|
||||||
|
# Project root (one level up from this compose file).
|
||||||
|
context: ..
|
||||||
|
dockerfile: Docker-AIO/Dockerfile
|
||||||
|
args:
|
||||||
|
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
|
||||||
|
- VITE_API_BASE=${VITE_API_BASE:-}
|
||||||
|
# Bare name = pass through only when set in the environment, so an unset
|
||||||
|
# PB_VERSION leaves the Dockerfile pin in place instead of overriding it
|
||||||
|
# with an empty string (which would resolve "latest" at build time).
|
||||||
|
- PB_VERSION
|
||||||
|
image: drivervault-aio
|
||||||
|
container_name: drivervault-aio
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# PocketBase — inside this container — is the process that reads and
|
||||||
|
# writes the objects, so the gateway has to be serving first, and the
|
||||||
|
# bucket has to exist before the bootstrap points PocketBase at it.
|
||||||
|
seaweedfs:
|
||||||
|
condition: service_healthy
|
||||||
|
seaweedfs-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||||
|
# inside this container, so plain localhost:8090 would never answer.
|
||||||
|
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||||
|
# screen permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||||
|
# add collections or fields the server needs, and a stack that skips the
|
||||||
|
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||||
|
# app_settings, the collection holding the plugin settings, which it
|
||||||
|
# creates on demand because it cannot serve the plugin panel without it.
|
||||||
|
# Every other schema change still depends on this flag. Turn it off only
|
||||||
|
# for a database you know already matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||||
|
# charger can only connect when TLS is terminated in front of it (set
|
||||||
|
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||||
|
# with OCPP_REQUIRE_TLS=false.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# supervisord passes these through to the API Server, whose bootstrap
|
||||||
|
# writes them into PocketBase's
|
||||||
|
# settings on every boot, idempotently. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos. The database and PocketBase's own
|
||||||
|
# backups stay on pb_data.
|
||||||
|
PB_S3_ENABLED: "true"
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
# The service name: a server-to-server call inside the compose network.
|
||||||
|
PB_S3_ENDPOINT: "http://seaweedfs:8333"
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||||
|
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||||
|
PB_S3_FORCE_PATH_STYLE: "true"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8090}:80" # Web App
|
||||||
|
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||||
|
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||||
|
volumes:
|
||||||
|
# The only volume: the API Server keeps no state on disk, so everything
|
||||||
|
# it owns — plugin settings included — lives in the database.
|
||||||
|
- pb_data:/pb/pb_data
|
||||||
|
healthcheck:
|
||||||
|
# All three processes must answer. Declared here as well as in the image so
|
||||||
|
# the check is visible, and works against an older pulled image.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
|
seaweed_data:
|
||||||
@@ -75,11 +75,3 @@ VITE_API_BASE=
|
|||||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
# POCKETBASE_URL=http://pocketbase:8070
|
# POCKETBASE_URL=http://pocketbase:8070
|
||||||
# WEBAPP_URL=http://web-app:8090
|
# WEBAPP_URL=http://web-app:8090
|
||||||
|
|
||||||
# --- Public hostname + TLS (docker-compose.tls.yml) --------------------------
|
|
||||||
# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this
|
|
||||||
# host from the internet, with ports 80 and 443 reaching it; the certificate is
|
|
||||||
# issued automatically on first boot. Setting it also points chargers at
|
|
||||||
# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on.
|
|
||||||
DV_DOMAIN=
|
|
||||||
DV_ACME_EMAIL=
|
|
||||||
@@ -97,11 +97,3 @@ API_BIND=127.0.0.1
|
|||||||
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
||||||
# settings included) is backed up by backing up this one path.
|
# settings included) is backed up by backing up this one path.
|
||||||
PB_DATA=pb_data
|
PB_DATA=pb_data
|
||||||
|
|
||||||
# --- Public hostname + TLS (docker-compose.tls.yml) --------------------------
|
|
||||||
# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this
|
|
||||||
# host from the internet, with ports 80 and 443 reaching it; the certificate is
|
|
||||||
# issued automatically on first boot. Setting it also points chargers at
|
|
||||||
# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on.
|
|
||||||
DV_DOMAIN=
|
|
||||||
DV_ACME_EMAIL=
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# DriverVault — production stack config.
|
||||||
|
# Copy to .env and fill in, then:
|
||||||
|
# docker compose -f docker-compose.prod.s3.yml pull
|
||||||
|
# docker compose -f docker-compose.prod.s3.yml up -d
|
||||||
|
|
||||||
|
# --- Registry images ---------------------------------------------------------
|
||||||
|
# Defaults point at the internal registry; override to pin a tag or use a mirror.
|
||||||
|
PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest
|
||||||
|
API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest
|
||||||
|
WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest
|
||||||
|
|
||||||
|
# --- PocketBase superuser ----------------------------------------------------
|
||||||
|
# Created/updated on the PocketBase container's first boot. The API Server uses
|
||||||
|
# these same credentials to manage the database. REQUIRED.
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip and
|
||||||
|
# create the first user by hand. This is the account you log in to the web app
|
||||||
|
# with — distinct from the PocketBase superuser above.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# --- API Server --------------------------------------------------------------
|
||||||
|
# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT).
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
AUTH_USERS_COLLECTION=users
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. Keep the default and terminate
|
||||||
|
# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the
|
||||||
|
# public wss:// base the charger should be pointed at (deriving it from request
|
||||||
|
# headers is unreliable behind a proxy). Turning the check off is for trusted
|
||||||
|
# networks only.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||||
|
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||||
|
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||||
|
# thing in the Anker app, then read the frames back out of the container log.
|
||||||
|
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||||
|
# seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG=
|
||||||
|
|
||||||
|
# The charger can dial either door: the API Server port directly, or the Web
|
||||||
|
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||||
|
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||||
|
# is whichever host the panel itself was reached on, which is the Web App.
|
||||||
|
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||||
|
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||||
|
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||||
|
# whatever the client said it was.
|
||||||
|
TRUST_FORWARDED_PROTO=false
|
||||||
|
|
||||||
|
# --- Ports -------------------------------------------------------------------
|
||||||
|
# WEB_PORT is the public front door (bound on all interfaces).
|
||||||
|
WEB_PORT=8090
|
||||||
|
# PocketBase admin UI and the API panel are bound to localhost only by default.
|
||||||
|
# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network.
|
||||||
|
PB_PORT=8070
|
||||||
|
PB_BIND=127.0.0.1
|
||||||
|
API_PORT=8080
|
||||||
|
API_BIND=127.0.0.1
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the values below are re-applied on every restart and win. Set
|
||||||
|
# them here to make a change permanent.
|
||||||
|
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||||
|
# the bundled pocketbase service; set it to reach one
|
||||||
|
# outside this stack.
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||||
|
# a container-to-container call, so it must be reachable
|
||||||
|
# from the API Server, not from your browser.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# POCKETBASE_URL=http://pocketbase:8070
|
||||||
|
# WEBAPP_URL=http://web-app:8090
|
||||||
|
|
||||||
|
# --- Storage -----------------------------------------------------------------
|
||||||
|
# One Docker-managed named volume by default. To store it on a host path
|
||||||
|
# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||||
|
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
|
||||||
|
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
||||||
|
# settings included) is backed up by backing up this one path.
|
||||||
|
PB_DATA=pb_data
|
||||||
|
|
||||||
|
# --- File storage: external S3 -----------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
|
||||||
|
# already exist. For a gateway on this Docker host use
|
||||||
|
# http://host.docker.internal:8333 — the compose file adds the host entry that
|
||||||
|
# makes that name resolve inside the containers.
|
||||||
|
PB_S3_ENDPOINT=http://10.2.1.10:8333
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION=us-east-1
|
||||||
|
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||||
|
PB_S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# DriverVault — production stack config.
|
||||||
|
# Copy to .env and fill in, then:
|
||||||
|
# docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||||
|
# docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||||
|
|
||||||
|
# --- Registry images ---------------------------------------------------------
|
||||||
|
# Defaults point at the internal registry; override to pin a tag or use a mirror.
|
||||||
|
PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest
|
||||||
|
API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest
|
||||||
|
WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest
|
||||||
|
|
||||||
|
# --- PocketBase superuser ----------------------------------------------------
|
||||||
|
# Created/updated on the PocketBase container's first boot. The API Server uses
|
||||||
|
# these same credentials to manage the database. REQUIRED.
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip and
|
||||||
|
# create the first user by hand. This is the account you log in to the web app
|
||||||
|
# with — distinct from the PocketBase superuser above.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# --- API Server --------------------------------------------------------------
|
||||||
|
# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT).
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
AUTH_USERS_COLLECTION=users
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. Keep the default and terminate
|
||||||
|
# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the
|
||||||
|
# public wss:// base the charger should be pointed at (deriving it from request
|
||||||
|
# headers is unreliable behind a proxy). Turning the check off is for trusted
|
||||||
|
# networks only.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||||
|
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||||
|
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||||
|
# thing in the Anker app, then read the frames back out of the container log.
|
||||||
|
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||||
|
# seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG=
|
||||||
|
|
||||||
|
# The charger can dial either door: the API Server port directly, or the Web
|
||||||
|
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||||
|
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||||
|
# is whichever host the panel itself was reached on, which is the Web App.
|
||||||
|
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||||
|
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||||
|
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||||
|
# whatever the client said it was.
|
||||||
|
TRUST_FORWARDED_PROTO=false
|
||||||
|
|
||||||
|
# --- Ports -------------------------------------------------------------------
|
||||||
|
# WEB_PORT is the public front door (bound on all interfaces).
|
||||||
|
WEB_PORT=8090
|
||||||
|
# PocketBase admin UI and the API panel are bound to localhost only by default.
|
||||||
|
# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network.
|
||||||
|
PB_PORT=8070
|
||||||
|
PB_BIND=127.0.0.1
|
||||||
|
API_PORT=8080
|
||||||
|
API_BIND=127.0.0.1
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the values below are re-applied on every restart and win. Set
|
||||||
|
# them here to make a change permanent.
|
||||||
|
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||||
|
# the bundled pocketbase service; set it to reach one
|
||||||
|
# outside this stack.
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||||
|
# a container-to-container call, so it must be reachable
|
||||||
|
# from the API Server, not from your browser.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# POCKETBASE_URL=http://pocketbase:8070
|
||||||
|
# WEBAPP_URL=http://web-app:8090
|
||||||
|
|
||||||
|
# --- Storage -----------------------------------------------------------------
|
||||||
|
# One Docker-managed named volume by default. To store it on a host path
|
||||||
|
# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||||
|
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
|
||||||
|
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
||||||
|
# settings included) is backed up by backing up this one path.
|
||||||
|
PB_DATA=pb_data
|
||||||
|
|
||||||
|
# --- File storage: SeaweedFS -------------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# The credentials do double duty: they configure the SeaweedFS gateway's single
|
||||||
|
# identity *and* are what PocketBase authenticates with. There are no safe
|
||||||
|
# defaults, and the stack refuses to start without them.
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION=us-east-1
|
||||||
|
|
||||||
|
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
|
||||||
|
# by default; set an absolute host path for a bind mount, the same way PB_DATA
|
||||||
|
# works above. Back it up alongside PB_DATA: from here on the attachments live
|
||||||
|
# here, not in the database volume.
|
||||||
|
SEAWEED_DATA=seaweed_data
|
||||||
|
# The gateway image, pinned so a redeploy months from now brings up the same one.
|
||||||
|
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||||
|
# The S3 port is published on loopback only — the stack reaches the gateway over
|
||||||
|
# the compose network, and this is for tools like aws-cli. Set
|
||||||
|
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
|
||||||
|
# SEAWEED_S3_BIND=127.0.0.1
|
||||||
|
# SEAWEED_S3_PORT=8333
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# DriverVault — production stack config, SeaweedFS split into its four roles.
|
||||||
|
# Copy to .env and fill in, then:
|
||||||
|
# docker compose -f docker-compose.prod.seaweedfs.split.yml pull
|
||||||
|
# docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
|
||||||
|
|
||||||
|
# --- Registry images ---------------------------------------------------------
|
||||||
|
# Defaults point at the internal registry; override to pin a tag or use a mirror.
|
||||||
|
PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest
|
||||||
|
API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest
|
||||||
|
WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest
|
||||||
|
|
||||||
|
# --- PocketBase superuser ----------------------------------------------------
|
||||||
|
# Created/updated on the PocketBase container's first boot. The API Server uses
|
||||||
|
# these same credentials to manage the database. REQUIRED.
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip and
|
||||||
|
# create the first user by hand. This is the account you log in to the web app
|
||||||
|
# with — distinct from the PocketBase superuser above.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# --- API Server --------------------------------------------------------------
|
||||||
|
# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT).
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
AUTH_USERS_COLLECTION=users
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. Keep the default and terminate
|
||||||
|
# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the
|
||||||
|
# public wss:// base the charger should be pointed at (deriving it from request
|
||||||
|
# headers is unreliable behind a proxy). Turning the check off is for trusted
|
||||||
|
# networks only.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||||
|
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||||
|
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||||
|
# thing in the Anker app, then read the frames back out of the container log.
|
||||||
|
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||||
|
# seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG=
|
||||||
|
|
||||||
|
# The charger can dial either door: the API Server port directly, or the Web
|
||||||
|
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||||
|
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||||
|
# is whichever host the panel itself was reached on, which is the Web App.
|
||||||
|
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||||
|
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||||
|
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||||
|
# whatever the client said it was.
|
||||||
|
TRUST_FORWARDED_PROTO=false
|
||||||
|
|
||||||
|
# --- Ports -------------------------------------------------------------------
|
||||||
|
# WEB_PORT is the public front door (bound on all interfaces).
|
||||||
|
WEB_PORT=8090
|
||||||
|
# PocketBase admin UI and the API panel are bound to localhost only by default.
|
||||||
|
# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network — which is
|
||||||
|
# what a stack running on a remote host needs, behind a reverse proxy.
|
||||||
|
PB_PORT=8070
|
||||||
|
PB_BIND=127.0.0.1
|
||||||
|
API_PORT=8080
|
||||||
|
API_BIND=127.0.0.1
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the values below are re-applied on every restart and win. Set
|
||||||
|
# them here to make a change permanent.
|
||||||
|
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||||
|
# the bundled pocketbase service; set it to reach one
|
||||||
|
# outside this stack.
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||||
|
# a container-to-container call, so it must be reachable
|
||||||
|
# from the API Server, not from your browser.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# POCKETBASE_URL=http://pocketbase:8070
|
||||||
|
# WEBAPP_URL=http://web-app:8090
|
||||||
|
|
||||||
|
# --- Storage -----------------------------------------------------------------
|
||||||
|
# One Docker-managed named volume by default. To store it on a host path
|
||||||
|
# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||||
|
# PB_DATA — the PocketBase database and its backups. The API Server keeps no
|
||||||
|
# state on disk, so everything it owns (plugin settings included) is backed up
|
||||||
|
# by backing up this one path — together with SEAWEED_DATA below.
|
||||||
|
PB_DATA=pb_data
|
||||||
|
|
||||||
|
# --- File storage: SeaweedFS -------------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# The credentials do double duty: seaweedfs-init writes them into the filer's
|
||||||
|
# IAM store as the identity named "drivervault" *and* they are what PocketBase
|
||||||
|
# authenticates with. There are no safe defaults, and the stack refuses to start
|
||||||
|
# without them. Change them here and restart to rotate: the seed updates the
|
||||||
|
# identity in place rather than adding a second one.
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION=us-east-1
|
||||||
|
|
||||||
|
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
|
||||||
|
# by default; set an absolute host path for a bind mount, the same way PB_DATA
|
||||||
|
# works above. Back it up alongside PB_DATA: from here on the attachments live
|
||||||
|
# here, not in the database volume.
|
||||||
|
#
|
||||||
|
# The master, volume and filer containers all mount it at /data, which is the
|
||||||
|
# layout `weed server -dir=/data` writes — so this file and
|
||||||
|
# docker-compose.prod.seaweedfs.yml are interchangeable on the same volume, with
|
||||||
|
# nothing to migrate either way.
|
||||||
|
SEAWEED_DATA=seaweed_data
|
||||||
|
# The SeaweedFS image, pinned so a redeploy months from now brings up the same
|
||||||
|
# one. All five SeaweedFS containers run it.
|
||||||
|
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||||
|
# The S3 port is published on loopback only — the stack reaches the gateway over
|
||||||
|
# the compose network, and this is for tools like aws-cli. Set
|
||||||
|
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
|
||||||
|
# SEAWEED_S3_BIND=127.0.0.1
|
||||||
|
# SEAWEED_S3_PORT=8333
|
||||||
|
#
|
||||||
|
# The master, volume and filer publish no host port at all. The admin UI below
|
||||||
|
# shows what they would: the volume server in particular serves file content by
|
||||||
|
# id with no authentication, so it stays on the compose network. Reach the
|
||||||
|
# others with `docker compose exec`.
|
||||||
|
|
||||||
|
# --- SeaweedFS admin UI ------------------------------------------------------
|
||||||
|
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
|
||||||
|
# Users, where further S3 identities are created and revoked. They land in the
|
||||||
|
# filer's IAM store, the same one seeded above, and the gateway picks them up
|
||||||
|
# without a restart.
|
||||||
|
#
|
||||||
|
# REQUIRED: weed disables authentication entirely when the password is empty,
|
||||||
|
# and this panel can mint credentials for the bucket.
|
||||||
|
SEAWEED_ADMIN_USER=admin
|
||||||
|
SEAWEED_ADMIN_PASSWORD=
|
||||||
|
# Optional view-only login.
|
||||||
|
SEAWEED_ADMIN_READONLY_USER=
|
||||||
|
SEAWEED_ADMIN_READONLY_PASSWORD=
|
||||||
|
# Bound to localhost by default, like PB_BIND and API_BIND. On a remote host
|
||||||
|
# that means unreachable — set 0.0.0.0 and put it behind the same reverse proxy
|
||||||
|
# as the other panels.
|
||||||
|
SEAWEED_ADMIN_BIND=127.0.0.1
|
||||||
|
SEAWEED_ADMIN_PORT=23646
|
||||||
|
# Its own small volume: session key and maintenance-task state, no object data.
|
||||||
|
SEAWEED_ADMIN_DATA=seaweed_admin
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Copy to .env and fill in. Used by the root docker-compose.s3.yml.
|
||||||
|
|
||||||
|
# --- PocketBase superuser (also used by the API Server to authenticate) ------
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave these blank and the
|
||||||
|
# schema is still created but no user is, leaving a stack you cannot log into.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# --- API Server -------------------------------------------------------------
|
||||||
|
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
|
||||||
|
# Native mobile apps are not subject to CORS.
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
AUTH_USERS_COLLECTION=users
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. This dev stack serves plain
|
||||||
|
# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the
|
||||||
|
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||||
|
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||||
|
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||||
|
# thing in the Anker app, then read the frames back out of the container log.
|
||||||
|
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||||
|
# seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG=
|
||||||
|
|
||||||
|
# The charger can dial either door: the API Server port directly, or the Web
|
||||||
|
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||||
|
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||||
|
# is whichever host the panel itself was reached on, which is the Web App.
|
||||||
|
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||||
|
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||||
|
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||||
|
# whatever the client said it was.
|
||||||
|
TRUST_FORWARDED_PROTO=false
|
||||||
|
|
||||||
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
|
PB_PORT=8070
|
||||||
|
API_PORT=8080
|
||||||
|
WEB_PORT=8090
|
||||||
|
|
||||||
|
# --- Web App build -----------------------------------------------------------
|
||||||
|
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
|
||||||
|
VITE_API_BASE=
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the values below are re-applied on every restart and win. Set
|
||||||
|
# them here to make a change permanent.
|
||||||
|
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||||
|
# the bundled pocketbase service; set it to reach one
|
||||||
|
# outside this stack.
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||||
|
# a container-to-container call, so it must be reachable
|
||||||
|
# from the API Server, not from your browser.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# POCKETBASE_URL=http://pocketbase:8070
|
||||||
|
# WEBAPP_URL=http://web-app:8090
|
||||||
|
|
||||||
|
# --- File storage: external S3 -----------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
|
||||||
|
# already exist. For a gateway on this Docker host use
|
||||||
|
# http://host.docker.internal:8333 — the compose file adds the host entry that
|
||||||
|
# makes that name resolve inside the containers.
|
||||||
|
PB_S3_ENDPOINT=http://host.docker.internal:8333
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION=us-east-1
|
||||||
|
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||||
|
PB_S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Copy to .env and fill in. Used by the root docker-compose.seaweedfs.yml.
|
||||||
|
|
||||||
|
# --- PocketBase superuser (also used by the API Server to authenticate) ------
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave these blank and the
|
||||||
|
# schema is still created but no user is, leaving a stack you cannot log into.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# --- API Server -------------------------------------------------------------
|
||||||
|
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
|
||||||
|
# Native mobile apps are not subject to CORS.
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
AUTH_USERS_COLLECTION=users
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. This dev stack serves plain
|
||||||
|
# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the
|
||||||
|
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||||
|
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||||
|
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||||
|
# thing in the Anker app, then read the frames back out of the container log.
|
||||||
|
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||||
|
# seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG=
|
||||||
|
|
||||||
|
# The charger can dial either door: the API Server port directly, or the Web
|
||||||
|
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||||
|
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||||
|
# is whichever host the panel itself was reached on, which is the Web App.
|
||||||
|
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||||
|
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||||
|
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||||
|
# whatever the client said it was.
|
||||||
|
TRUST_FORWARDED_PROTO=false
|
||||||
|
|
||||||
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
|
PB_PORT=8070
|
||||||
|
API_PORT=8080
|
||||||
|
WEB_PORT=8090
|
||||||
|
|
||||||
|
# --- Web App build -----------------------------------------------------------
|
||||||
|
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
|
||||||
|
VITE_API_BASE=
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the values below are re-applied on every restart and win. Set
|
||||||
|
# them here to make a change permanent.
|
||||||
|
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||||
|
# the bundled pocketbase service; set it to reach one
|
||||||
|
# outside this stack.
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||||
|
# a container-to-container call, so it must be reachable
|
||||||
|
# from the API Server, not from your browser.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# POCKETBASE_URL=http://pocketbase:8070
|
||||||
|
# WEBAPP_URL=http://web-app:8090
|
||||||
|
|
||||||
|
# --- File storage: SeaweedFS -------------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# The credentials do double duty: they configure the SeaweedFS gateway's single
|
||||||
|
# identity *and* are what PocketBase authenticates with. There are no safe
|
||||||
|
# defaults, and the stack refuses to start without them.
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION=us-east-1
|
||||||
|
# The gateway image, pinned so a rebuild months from now brings up the same one.
|
||||||
|
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||||
|
# Host port for the S3 API, so aws-cli and friends can reach it while developing.
|
||||||
|
# SEAWEED_S3_PORT=8333
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Copy to .env and fill in. Used by docker-compose.seaweedfs.split.yml — the
|
||||||
|
# same stack as .env.seaweedfs.example, with SeaweedFS running as separate
|
||||||
|
# master / volume / filer / S3 containers plus the SeaweedFS admin UI.
|
||||||
|
|
||||||
|
# --- PocketBase superuser (also used by the API Server to authenticate) ------
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave these blank and the
|
||||||
|
# schema is still created but no user is, leaving a stack you cannot log into.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||||
|
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||||
|
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||||
|
# settings, on demand — but only that one.) Set false only for a database you
|
||||||
|
# know already matches the release.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# --- API Server -------------------------------------------------------------
|
||||||
|
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
|
||||||
|
# Native mobile apps are not subject to CORS.
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
AUTH_USERS_COLLECTION=users
|
||||||
|
|
||||||
|
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||||
|
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||||
|
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||||
|
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||||
|
# non-TLS connections are rejected by default. This dev stack serves plain
|
||||||
|
# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the
|
||||||
|
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS=true
|
||||||
|
OCPP_PUBLIC_URL=
|
||||||
|
|
||||||
|
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||||
|
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||||
|
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||||
|
# thing in the Anker app, then read the frames back out of the container log.
|
||||||
|
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||||
|
# seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG=
|
||||||
|
|
||||||
|
# The charger can dial either door: the API Server port directly, or the Web
|
||||||
|
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||||
|
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||||
|
# is whichever host the panel itself was reached on, which is the Web App.
|
||||||
|
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||||
|
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||||
|
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||||
|
# whatever the client said it was.
|
||||||
|
TRUST_FORWARDED_PROTO=false
|
||||||
|
|
||||||
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
|
PB_PORT=8070
|
||||||
|
API_PORT=8080
|
||||||
|
WEB_PORT=8090
|
||||||
|
|
||||||
|
# --- Web App build -----------------------------------------------------------
|
||||||
|
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
|
||||||
|
VITE_API_BASE=
|
||||||
|
|
||||||
|
# --- Settings the API Server panel can also change ---------------------------
|
||||||
|
# The panel's Settings screens apply these immediately, but only for the life of
|
||||||
|
# the container — the values below are re-applied on every restart and win. Set
|
||||||
|
# them here to make a change permanent.
|
||||||
|
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||||
|
# the bundled pocketbase service; set it to reach one
|
||||||
|
# outside this stack.
|
||||||
|
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||||
|
# a container-to-container call, so it must be reachable
|
||||||
|
# from the API Server, not from your browser.
|
||||||
|
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||||
|
# POCKETBASE_URL=http://pocketbase:8070
|
||||||
|
# WEBAPP_URL=http://web-app:8090
|
||||||
|
|
||||||
|
# --- File storage: SeaweedFS -------------------------------------------------
|
||||||
|
# PocketBase keeps its record files — document scans, service and refill
|
||||||
|
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||||
|
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||||
|
#
|
||||||
|
# The credentials do double duty: seaweedfs-init writes them into the filer's
|
||||||
|
# IAM store as the identity named "drivervault" *and* they are what PocketBase
|
||||||
|
# authenticates with. There are no safe defaults, and the stack refuses to start
|
||||||
|
# without them. Change them here and restart to rotate: the seed updates the
|
||||||
|
# identity in place rather than adding a second one.
|
||||||
|
PB_S3_ACCESS_KEY=
|
||||||
|
PB_S3_SECRET=
|
||||||
|
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||||
|
PB_S3_BUCKET=drivervault
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION=us-east-1
|
||||||
|
# The SeaweedFS image, pinned so a rebuild months from now brings up the same
|
||||||
|
# one. All five SeaweedFS containers run it.
|
||||||
|
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||||
|
|
||||||
|
# --- SeaweedFS admin UI ------------------------------------------------------
|
||||||
|
# http://localhost:23646 — cluster topology, volumes, buckets, and
|
||||||
|
# Object Store → Users, where further S3 identities are created and revoked.
|
||||||
|
# They land in the filer's IAM store, the same one seeded above, and the gateway
|
||||||
|
# picks them up without a restart.
|
||||||
|
#
|
||||||
|
# REQUIRED: weed disables authentication entirely when the password is empty,
|
||||||
|
# and this panel can mint credentials for the bucket.
|
||||||
|
SEAWEED_ADMIN_USER=admin
|
||||||
|
SEAWEED_ADMIN_PASSWORD=
|
||||||
|
# SEAWEED_ADMIN_PORT=23646
|
||||||
|
|
||||||
|
# --- SeaweedFS host ports (optional; defaults shown) -------------------------
|
||||||
|
# Published for aws-cli, `weed shell` and poking around while developing. The
|
||||||
|
# stack itself reaches every one of these over the compose network.
|
||||||
|
# Note SEAWEED_VOLUME_PORT: the volume server serves file content by id with NO
|
||||||
|
# authentication, so do not carry this mapping over to a shared machine. It
|
||||||
|
# lands on 8081 because API_PORT already has 8080.
|
||||||
|
# SEAWEED_MASTER_PORT=9333
|
||||||
|
# SEAWEED_VOLUME_PORT=8081
|
||||||
|
# SEAWEED_FILER_PORT=8888
|
||||||
|
# SEAWEED_S3_PORT=8333
|
||||||
|
|
||||||
|
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||||
|
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||||
|
# storage section of README.md first.
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Caddy in front of the stack: one hostname, TLS from Let's Encrypt, everything
|
|
||||||
# behind it spoken to over the compose network in plain HTTP.
|
|
||||||
#
|
|
||||||
# Both doors are the same door here. Browsers get the Web App; chargers dial
|
|
||||||
# /ocpp/{serial} on the same hostname, and the BFF carries that through to the
|
|
||||||
# API Server. Caddy proxies WebSocket upgrades without being told to, so there
|
|
||||||
# is nothing to configure for the charger case.
|
|
||||||
#
|
|
||||||
# DV_DOMAIN and DV_ACME_EMAIL come from .env via docker-compose.tls.yml.
|
|
||||||
|
|
||||||
{
|
|
||||||
email {$DV_ACME_EMAIL}
|
|
||||||
}
|
|
||||||
|
|
||||||
{$DV_DOMAIN} {
|
|
||||||
encode zstd gzip
|
|
||||||
|
|
||||||
# X-Forwarded-Proto is set by Caddy to the scheme the client used. The API
|
|
||||||
# Server reads it to decide a charger arrived over TLS, which is why the
|
|
||||||
# web-app service is given TRUST_FORWARDED_PROTO=true — without that the BFF
|
|
||||||
# would overwrite this header with its own plaintext hop and a charger would
|
|
||||||
# be rejected under OCPP_REQUIRE_TLS.
|
|
||||||
reverse_proxy web-app:8090
|
|
||||||
}
|
|
||||||
+110
-22
@@ -78,6 +78,102 @@ instead. PocketBase runs as root, so a root-owned host directory is fine.
|
|||||||
> `WEBAPP_URL` and `CORS_ALLOW_ORIGINS` in `.env` to change them permanently —
|
> `WEBAPP_URL` and `CORS_ALLOW_ORIGINS` in `.env` to change them permanently —
|
||||||
> in this stack the compose environment wins over anything the panel writes.
|
> in this stack the compose environment wins over anything the panel writes.
|
||||||
|
|
||||||
|
## File storage (SeaweedFS / S3)
|
||||||
|
|
||||||
|
Uploaded files — document scans, service and refill receipts, workshop invoices,
|
||||||
|
part photos — live inside `pb_data` by default, next to the database. Two further
|
||||||
|
compose files put them in an S3 bucket instead, so the blobs and the database can
|
||||||
|
be sized, backed up and moved independently. Nothing else changes: an attachment has
|
||||||
|
always been fetched through the API Server (`GET /api/service-records/{id}/file`),
|
||||||
|
never from a storage URL, so the Web App, the phone app and the Home Assistant
|
||||||
|
plugin cannot tell the difference.
|
||||||
|
|
||||||
|
Each shape is one self-contained compose file — nothing to layer, nothing to
|
||||||
|
remember — with an `.env` example of the same name:
|
||||||
|
|
||||||
|
| Shape | From the registry | From source |
|
||||||
|
|---|---|---|
|
||||||
|
| **Local storage** — the default, unchanged | `docker-compose.prod.yml` | `docker-compose.yml` |
|
||||||
|
| **SeaweedFS in this stack** | `docker-compose.prod.seaweedfs.yml` | `docker-compose.seaweedfs.yml` |
|
||||||
|
| **SeaweedFS, split into its roles** | `docker-compose.prod.seaweedfs.split.yml` | `docker-compose.seaweedfs.split.yml` |
|
||||||
|
| **An S3 endpoint outside it** | `docker-compose.prod.s3.yml` | `docker-compose.s3.yml` |
|
||||||
|
|
||||||
|
So `docker-compose.prod.seaweedfs.yml` is configured from
|
||||||
|
`.env.prod.seaweedfs.example`, `docker-compose.s3.yml` from `.env.s3.example`,
|
||||||
|
and so on:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.prod.seaweedfs.example .env # then edit it — PB_S3_* have no defaults
|
||||||
|
docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||||
|
docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `PB_S3_ACCESS_KEY` and `PB_S3_SECRET` first — both storage files refuse to
|
||||||
|
start without them. The SeaweedFS ones add a `seaweedfs` container (master,
|
||||||
|
volume, filer and S3 gateway in one process, on its own `seaweed_data` volume)
|
||||||
|
plus a one-shot `seaweedfs-init` that creates the bucket, because PocketBase never
|
||||||
|
issues a `CreateBucket` of its own. The external-S3 ones add no containers at
|
||||||
|
all: set `PB_S3_ENDPOINT`, and create the bucket yourself.
|
||||||
|
|
||||||
|
### Split SeaweedFS
|
||||||
|
|
||||||
|
`weed server -s3` runs master, volume, filer and gateway as four goroutines in
|
||||||
|
one process. The `.split.` files run them as four containers, plus a fifth: the
|
||||||
|
SeaweedFS **admin UI** on port 23646, where the cluster can be inspected and —
|
||||||
|
under *Object Store → Users* — further S3 identities minted and revoked. Split
|
||||||
|
also gets you per-role restarts and upgrades, per-role Prometheus metrics, and
|
||||||
|
room to add a second volume server later.
|
||||||
|
|
||||||
|
Identities work differently there, and it matters. SeaweedFS reads credentials
|
||||||
|
from, in descending priority: an `-s3.config` file, the filer's IAM store, then
|
||||||
|
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — and a higher source *replaces*
|
||||||
|
a lower one rather than adding to it. The single-process files use the env vars,
|
||||||
|
which is why nothing else may write identities there: the first user added in a
|
||||||
|
panel would displace PocketBase's key. So in the split files `seaweedfs-init`
|
||||||
|
seeds PocketBase's identity into the filer's store instead — the same store the
|
||||||
|
admin UI writes — and the gateway runs with no config file at all. One source of
|
||||||
|
truth, PocketBase's key visible in the panel beside every other, and new keys
|
||||||
|
picked up without a restart. Rotating `PB_S3_SECRET` in `.env` and restarting
|
||||||
|
updates that identity in place.
|
||||||
|
|
||||||
|
Set `SEAWEED_ADMIN_PASSWORD`: `weed admin` serves the panel with no
|
||||||
|
authentication when it is empty, and a panel that can mint bucket credentials is
|
||||||
|
the bucket. In the prod file it is bound to loopback like `PB_BIND` and
|
||||||
|
`API_BIND`, so a remote host needs `SEAWEED_ADMIN_BIND=0.0.0.0` behind the same
|
||||||
|
reverse proxy. That file publishes nothing for master, volume and filer — the
|
||||||
|
volume server serves file content by id with no authentication of any kind, and
|
||||||
|
the admin UI already shows what those ports would.
|
||||||
|
|
||||||
|
Switching between `docker-compose.seaweedfs.yml` and its `.split.` twin needs no
|
||||||
|
migration: master, volume and filer share one `/data` mount, which is exactly
|
||||||
|
the layout `weed server -dir=/data` writes.
|
||||||
|
|
||||||
|
On every boot the API Server's bootstrap writes PocketBase's *Files storage*
|
||||||
|
settings from those variables, then asks PocketBase to prove it can reach the
|
||||||
|
bucket. Watch for it in the log:
|
||||||
|
|
||||||
|
```
|
||||||
|
[api] bootstrap: ✓ file storage → S3 (drivervault at http://seaweedfs:8333)
|
||||||
|
[api] bootstrap: ✓ S3 storage reachable
|
||||||
|
```
|
||||||
|
|
||||||
|
A boot that finds the settings already correct logs `• file storage already on S3`
|
||||||
|
and writes nothing.
|
||||||
|
|
||||||
|
Two things to know before turning it on:
|
||||||
|
|
||||||
|
- **Existing files are not migrated.** PocketBase copies nothing when the setting
|
||||||
|
flips, so attachments uploaded before the switch stop resolving. Copy
|
||||||
|
`pb_data/storage/<collectionId>/<recordId>/<file>` into the bucket root, keeping
|
||||||
|
that layout, *before* enabling it — or start from a stack with no attachments.
|
||||||
|
- **Going back to the plain compose file is not an off switch.** It leaves
|
||||||
|
PocketBase pointed at
|
||||||
|
the bucket, deliberately: files already written there are reachable only while
|
||||||
|
it is. Move them back and turn it off in PocketBase's own admin UI. For the same
|
||||||
|
reason a rotation of `PB_S3_SECRET` alone is invisible to the bootstrap —
|
||||||
|
PocketBase masks the stored secret on read — so change another `PB_S3_*` value
|
||||||
|
alongside it, or set it in the admin UI.
|
||||||
|
|
||||||
## Charger control (OCPP)
|
## Charger control (OCPP)
|
||||||
|
|
||||||
Chargers in own/proxy mode dial in to `/ocpp/{serial}`, authenticating with a
|
Chargers in own/proxy mode dial in to `/ocpp/{serial}`, authenticating with a
|
||||||
@@ -93,34 +189,26 @@ A plaintext `ws://` puts the control token on the wire in the clear, so
|
|||||||
|
|
||||||
### With a public hostname and TLS
|
### With a public hostname and TLS
|
||||||
|
|
||||||
`docker-compose.tls.yml` adds Caddy in front of the stack: one hostname, a
|
Nothing in this stack terminates TLS. Put your own reverse proxy in front of the
|
||||||
certificate issued on first boot, and everything behind it spoken to over the
|
Web App port, give it a certificate and a hostname, and set four things by hand:
|
||||||
compose network. Browsers and chargers arrive at the same name.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# in .env
|
# in .env
|
||||||
DV_DOMAIN=drivervault.example.com
|
OCPP_PUBLIC_URL=wss://drivervault.example.com
|
||||||
DV_ACME_EMAIL=you@example.com
|
OCPP_REQUIRE_TLS=true
|
||||||
|
CORS_ALLOW_ORIGINS=https://drivervault.example.com
|
||||||
docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
|
TRUST_FORWARDED_PROTO=true
|
||||||
```
|
```
|
||||||
|
|
||||||
The overlay sets the rest for you: `OCPP_PUBLIC_URL=wss://$DV_DOMAIN`,
|
`TRUST_FORWARDED_PROTO` is the easy one to miss: without it the Web App's BFF
|
||||||
`OCPP_REQUIRE_TLS=true`, `CORS_ALLOW_ORIGINS=https://$DV_DOMAIN`, and
|
overwrites the proxy's `X-Forwarded-Proto` with its own plaintext hop and every
|
||||||
`TRUST_FORWARDED_PROTO=true` on the Web App so the BFF passes Caddy's
|
charger is rejected as insecure. Only set it when that proxy really is the only
|
||||||
`X-Forwarded-Proto` to the API Server instead of overwriting it with its own
|
way in — otherwise a charger could claim `wss` over a plaintext connection.
|
||||||
plaintext hop. Point the charger's OCPP backend at the endpoint the panel then
|
|
||||||
shows, with the control token as its authorization key.
|
|
||||||
|
|
||||||
Two things the overlay cannot arrange: `DV_DOMAIN` must resolve to the host from
|
Point the charger's OCPP backend at the endpoint the panel then shows, with the
|
||||||
the internet with ports 80 and 443 reaching it (Caddy needs `:80` for the ACME
|
control token as its authorization key. The charger has to resolve that hostname
|
||||||
challenge), and the charger must be able to resolve that name too — behind NAT
|
too: behind NAT that usually means hairpin NAT, or a split-DNS entry pointing the
|
||||||
that usually means hairpin NAT or a split-DNS entry pointing it at the LAN
|
name at the LAN address.
|
||||||
address.
|
|
||||||
|
|
||||||
Using a proxy you already run instead? Terminate TLS there, forward to the Web
|
|
||||||
App port, and set the same four variables by hand — the `X-Forwarded-Proto` one
|
|
||||||
included, or chargers will be rejected as insecure.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
name: drivervault
|
||||||
|
|
||||||
|
# Production DriverVault stack, with external S3 — pulls prebuilt images from
|
||||||
|
# the registry instead of building from source. Self-contained: one file, no
|
||||||
|
# overlays. Everything an operator needs to set lives in .env.
|
||||||
|
#
|
||||||
|
# 1. cp .env.prod.s3.example .env (then edit it — PB_S3_* especially)
|
||||||
|
# 2. docker compose -f docker-compose.prod.s3.yml pull
|
||||||
|
# 3. docker compose -f docker-compose.prod.s3.yml up -d
|
||||||
|
#
|
||||||
|
# This is docker-compose.prod.yml pointed at an S3 endpoint that already exists
|
||||||
|
# somewhere else — its own host, another compose project, or any S3-compatible
|
||||||
|
# service. PocketBase keeps its record files — document scans, service and
|
||||||
|
# refill receipts, workshop invoices, part photos — in that bucket instead of on
|
||||||
|
# the pb_data volume. The database and PocketBase's own backups stay on PB_DATA.
|
||||||
|
# Clients cannot tell the difference: an attachment has always been fetched
|
||||||
|
# through the API Server, never from a storage URL.
|
||||||
|
#
|
||||||
|
# The bucket must already exist, and nothing here runs the gateway. For a
|
||||||
|
# SeaweedFS that comes up with the stack, use docker-compose.prod.seaweedfs.yml.
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
#
|
||||||
|
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||||
|
#
|
||||||
|
# On first boot:
|
||||||
|
# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing).
|
||||||
|
# • the API Server creates any missing collections, reconciles existing ones,
|
||||||
|
# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*.
|
||||||
|
# Both steps are idempotent, so restarts and upgrades are safe.
|
||||||
|
|
||||||
|
services:
|
||||||
|
pocketbase:
|
||||||
|
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
|
||||||
|
container_name: drivervault-pocketbase
|
||||||
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||||
|
# Harmless when the endpoint is somewhere else entirely.
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
environment:
|
||||||
|
# The superuser is created/updated on boot (the API Server authenticates
|
||||||
|
# with it). This is the only place the first superuser can be created — the
|
||||||
|
# REST API cannot bootstrap it.
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
volumes:
|
||||||
|
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
|
||||||
|
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||||
|
ports:
|
||||||
|
# Bound to localhost by default — the admin UI (/_/) is reachable only on
|
||||||
|
# the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network.
|
||||||
|
- "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
api-server:
|
||||||
|
image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}"
|
||||||
|
container_name: drivervault-api
|
||||||
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||||
|
# Harmless when the endpoint is somewhere else entirely.
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
API_ADDR: ":8080"
|
||||||
|
# Reach PocketBase by its service name on the internal network. Override
|
||||||
|
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||||
|
# this stack — that is also how you make a retarget done from the panel
|
||||||
|
# permanent, since the panel's change lasts only for the container's life.
|
||||||
|
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||||
|
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||||
|
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||||
|
# Probed by the panel status page. This is a server-to-server call inside
|
||||||
|
# the compose network, so the default is the service name — plain
|
||||||
|
# localhost:8090 would resolve to this container itself. Override
|
||||||
|
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||||
|
# permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||||
|
# add collections or fields the server needs, and a stack that skips the
|
||||||
|
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||||
|
# app_settings, the collection holding the plugin settings, which it
|
||||||
|
# creates on demand because it cannot serve the plugin panel without it.
|
||||||
|
# Every other schema change still depends on this flag. Turn it off only
|
||||||
|
# for a database you know already matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||||
|
# reach the server over TLS. Behind a TLS-terminating reverse proxy, set
|
||||||
|
# OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can
|
||||||
|
# reach this port; only drop OCPP_REQUIRE_TLS on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||||
|
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||||
|
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||||
|
# do something in the Anker app while a control read holds the connection
|
||||||
|
# open, and read the frames back out of the log. Off by default: with it on
|
||||||
|
# a charger under a live trigger writes a line every few seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||||
|
# settings on every boot, idempotently. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos. The database and PocketBase's own
|
||||||
|
# backups stay on PB_DATA. The bucket must already exist: nothing here
|
||||||
|
# creates it.
|
||||||
|
PB_S3_ENABLED: "true"
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
|
||||||
|
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||||
|
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
|
||||||
|
ports:
|
||||||
|
# Localhost-only by default (the Web App reaches it over the internal
|
||||||
|
# network). Set API_BIND=0.0.0.0 to expose the API panel — and the
|
||||||
|
# /ocpp/{serial} endpoint chargers dial into — on the network.
|
||||||
|
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
|
||||||
|
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||||
|
# plugin settings included, lives in PocketBase under PB_DATA.
|
||||||
|
healthcheck:
|
||||||
|
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||||
|
# depends_on gate below still works against an older pulled image.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
web-app:
|
||||||
|
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
|
||||||
|
container_name: drivervault-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||||
|
# serving rather than merely started.
|
||||||
|
api-server:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||||
|
# told to dial — to the API Server over the internal network.
|
||||||
|
API_BASE: "http://api-server:8080"
|
||||||
|
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||||
|
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||||
|
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||||
|
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||||
|
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||||
|
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||||
|
ports:
|
||||||
|
# The public front door. Bound on all interfaces so browsers can reach it.
|
||||||
|
- "${WEB_PORT:-8090}:8090"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
name: drivervault
|
||||||
|
|
||||||
|
# Production DriverVault stack, with SeaweedFS split into its four roles —
|
||||||
|
# pulls prebuilt images from the registry instead of building from source.
|
||||||
|
# Self-contained: one file, no overlays. Everything an operator needs to set
|
||||||
|
# lives in .env.
|
||||||
|
#
|
||||||
|
# 1. cp .env.prod.seaweedfs.split.example .env (then edit it)
|
||||||
|
# 2. docker compose -f docker-compose.prod.seaweedfs.split.yml pull
|
||||||
|
# 3. docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
|
||||||
|
#
|
||||||
|
# This is docker-compose.prod.seaweedfs.yml with the storage layer taken apart.
|
||||||
|
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
|
||||||
|
# process; here each is its own container, plus the SeaweedFS admin UI. What
|
||||||
|
# that buys:
|
||||||
|
#
|
||||||
|
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
|
||||||
|
# where S3 identities are created and revoked without touching a file;
|
||||||
|
# • per-role restart, upgrade and Prometheus metrics;
|
||||||
|
# • room to add a second volume server later, on this host or another.
|
||||||
|
#
|
||||||
|
# What it costs: five containers instead of one, five healthchecks to keep the
|
||||||
|
# boot order honest, and one more port worth binding carefully. If none of the
|
||||||
|
# above is wanted, use docker-compose.prod.seaweedfs.yml — the S3 behaviour is
|
||||||
|
# identical.
|
||||||
|
#
|
||||||
|
# The on-disk layout is deliberately the same as the single-process file's:
|
||||||
|
# master, volume and filer share one /data mount, exactly as `weed server -dir`
|
||||||
|
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
|
||||||
|
# filename overlap). So the two files are interchangeable on the same SEAWEED_DATA,
|
||||||
|
# with no migration either way. A *second* volume server would need its own.
|
||||||
|
#
|
||||||
|
# Only the S3 gateway and the admin UI publish a host port. Master, volume and
|
||||||
|
# filer are reachable over the compose network, through the admin UI, or with
|
||||||
|
# `docker compose exec` — the volume server in particular serves file content by
|
||||||
|
# id with no authentication at all, so it has no business on a public interface.
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
#
|
||||||
|
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||||
|
#
|
||||||
|
# On first boot:
|
||||||
|
# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing).
|
||||||
|
# • the API Server creates any missing collections, reconciles existing ones,
|
||||||
|
# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*.
|
||||||
|
# Both steps are idempotent, so restarts and upgrades are safe.
|
||||||
|
|
||||||
|
services:
|
||||||
|
# --- SeaweedFS: master -----------------------------------------------------
|
||||||
|
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
|
||||||
|
# the other roles are told to reach it by, so it must be the service name and
|
||||||
|
# not the container IP the process would otherwise detect.
|
||||||
|
seaweedfs-master:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-master
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
|
||||||
|
-volumeSizeLimitMB=1024 -metricsPort=9324
|
||||||
|
volumes:
|
||||||
|
# Named volume by default; set SEAWEED_DATA to a host path in .env for a
|
||||||
|
# bind mount, exactly as PB_DATA works. Back it up alongside PB_DATA —
|
||||||
|
# from here on the attachments live here, not in the database volume.
|
||||||
|
- "${SEAWEED_DATA:-seaweed_data}:/data"
|
||||||
|
# No published port: the master UI is one of the pages the admin UI serves.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: volume server ---------------------------------------------
|
||||||
|
# Where the bytes actually land. -max=0 lets it size itself from free disk
|
||||||
|
# rather than the default cap of 8 volumes.
|
||||||
|
seaweedfs-volume:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-volume
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
|
||||||
|
-port=8080 -dir=/data -max=0 -metricsPort=9325
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-master:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- "${SEAWEED_DATA:-seaweed_data}:/data"
|
||||||
|
# No published port, and this one is not an oversight: 8080 serves file
|
||||||
|
# content by file id with NO authentication — the S3 credentials do not
|
||||||
|
# apply to it. Publishing it would publish every attachment in the stack.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: filer ------------------------------------------------------
|
||||||
|
# Gives the flat volume store a directory tree — buckets, object keys — and
|
||||||
|
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
|
||||||
|
# embedded leveldb goes; without it that would be the container's working
|
||||||
|
# directory, and the identities would not survive a recreate.
|
||||||
|
seaweedfs-filer:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-filer
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
|
||||||
|
-port=8888 -defaultStoreDir=/data -metricsPort=9326
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-volume:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- "${SEAWEED_DATA:-seaweed_data}:/data"
|
||||||
|
# No published port. The filer's gRPC side (8888 + 10000) carries the IAM
|
||||||
|
# service that mints S3 credentials; keep both ends of it on the compose
|
||||||
|
# network.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: bucket and identity seed ----------------------------------
|
||||||
|
# Runs once and exits, before the gateway starts. Two jobs:
|
||||||
|
#
|
||||||
|
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
|
||||||
|
# 2. write PocketBase's S3 identity into the filer's IAM store.
|
||||||
|
#
|
||||||
|
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
|
||||||
|
# docker-compose.prod.seaweedfs.yml does. Those env vars are the *lowest*
|
||||||
|
# priority credential source in SeaweedFS: they are read only while the filer's
|
||||||
|
# store is empty, so the first identity added in the admin UI would silently
|
||||||
|
# displace them and lock PocketBase out. Seeding the store the admin UI itself
|
||||||
|
# writes leaves one source of truth, and the key PocketBase uses appears under
|
||||||
|
# Object Store → Users like any other.
|
||||||
|
#
|
||||||
|
# Both commands update in place, so every later boot re-applies the values from
|
||||||
|
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
|
||||||
|
# reaches the gateway.
|
||||||
|
#
|
||||||
|
# The closing grep is the gate: an empty IAM store means the gateway would come
|
||||||
|
# up in its allow-anyone default, so this fails loudly instead and the gateway
|
||||||
|
# below never starts. No `|| true` here, deliberately.
|
||||||
|
seaweedfs-init:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-init
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-filer:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# Passed as env and expanded by the shell inside the container, so the
|
||||||
|
# secret stays out of the container's argv.
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
printf '%s\n' \
|
||||||
|
"s3.bucket.create -name $$PB_S3_BUCKET" \
|
||||||
|
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
|
||||||
|
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
|
||||||
|
echo "s3.configure" \
|
||||||
|
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
|
||||||
|
| grep -q "$$PB_S3_ACCESS_KEY"
|
||||||
|
|
||||||
|
# --- SeaweedFS: S3 gateway -------------------------------------------------
|
||||||
|
# The endpoint PocketBase talks to. No -config file: with only -filer given,
|
||||||
|
# credentials come from the filer's IAM store, which is what lets the admin UI
|
||||||
|
# add and revoke identities without a restart. A config file would take
|
||||||
|
# priority over that store and make the admin UI's users inert.
|
||||||
|
seaweedfs-s3:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-s3
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
|
||||||
|
-metricsPort=9327
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-filer:
|
||||||
|
condition: service_healthy
|
||||||
|
# Never serve before an identity exists — see seaweedfs-init above.
|
||||||
|
seaweedfs-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
ports:
|
||||||
|
# Loopback only: the stack reaches the gateway over the compose network,
|
||||||
|
# so this is here for `aws s3 ls --endpoint-url http://127.0.0.1:8333` and
|
||||||
|
# nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to expose it, and mean it.
|
||||||
|
- "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8333/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: admin UI ---------------------------------------------------
|
||||||
|
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
|
||||||
|
# Users, where S3 access keys are minted and revoked. It finds the filer
|
||||||
|
# through the master, so -master is all it needs.
|
||||||
|
#
|
||||||
|
# Bound to loopback by default, like the PocketBase and API panels: on a remote
|
||||||
|
# host that means unreachable, so set SEAWEED_ADMIN_BIND=0.0.0.0 the same way
|
||||||
|
# PB_BIND and API_BIND get set — and put it behind the same reverse proxy.
|
||||||
|
#
|
||||||
|
# An unauthenticated panel that can mint credentials for the bucket *is* the
|
||||||
|
# bucket, so the password is required rather than defaulted — weed leaves auth
|
||||||
|
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
|
||||||
|
# flag, which keeps it off the process command line. -dataDir persists the
|
||||||
|
# session key and the maintenance-task settings.
|
||||||
|
seaweedfs-admin:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-admin
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
|
||||||
|
-metricsPort=9328
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-master:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
|
||||||
|
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
|
||||||
|
# Optional view-only login. weed ignores it unless the admin password
|
||||||
|
# above is set, which it is.
|
||||||
|
WEED_ADMIN_READONLY_USER: "${SEAWEED_ADMIN_READONLY_USER:-}"
|
||||||
|
WEED_ADMIN_READONLY_PASSWORD: "${SEAWEED_ADMIN_READONLY_PASSWORD:-}"
|
||||||
|
volumes:
|
||||||
|
# Its own small volume: session key and maintenance state, no object data.
|
||||||
|
- "${SEAWEED_ADMIN_DATA:-seaweed_admin}:/data"
|
||||||
|
ports:
|
||||||
|
- "${SEAWEED_ADMIN_BIND:-127.0.0.1}:${SEAWEED_ADMIN_PORT:-23646}:23646"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
pocketbase:
|
||||||
|
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
|
||||||
|
container_name: drivervault-pocketbase
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# PocketBase is the process that reads and writes the objects, so the
|
||||||
|
# gateway has to be serving before it is asked to store anything.
|
||||||
|
seaweedfs-s3:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# The superuser is created/updated on boot (the API Server authenticates
|
||||||
|
# with it). This is the only place the first superuser can be created — the
|
||||||
|
# REST API cannot bootstrap it.
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
volumes:
|
||||||
|
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
|
||||||
|
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||||
|
ports:
|
||||||
|
# Bound to localhost by default — the admin UI (/_/) is reachable only on
|
||||||
|
# the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network.
|
||||||
|
- "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
api-server:
|
||||||
|
image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}"
|
||||||
|
container_name: drivervault-api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
|
# The bucket must exist before the bootstrap points PocketBase at it.
|
||||||
|
seaweedfs-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
API_ADDR: ":8080"
|
||||||
|
# Reach PocketBase by its service name on the internal network. Override
|
||||||
|
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||||
|
# this stack — that is also how you make a retarget done from the panel
|
||||||
|
# permanent, since the panel's change lasts only for the container's life.
|
||||||
|
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||||
|
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||||
|
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||||
|
# Probed by the panel status page. This is a server-to-server call inside
|
||||||
|
# the compose network, so the default is the service name — plain
|
||||||
|
# localhost:8090 would resolve to this container itself. Override
|
||||||
|
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||||
|
# permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||||
|
# add collections or fields the server needs, and a stack that skips the
|
||||||
|
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||||
|
# app_settings, the collection holding the plugin settings, which it
|
||||||
|
# creates on demand because it cannot serve the plugin panel without it.
|
||||||
|
# Every other schema change still depends on this flag. Turn it off only
|
||||||
|
# for a database you know already matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||||
|
# reach the server over TLS. Behind a TLS-terminating reverse proxy, set
|
||||||
|
# OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can
|
||||||
|
# reach this port; only drop OCPP_REQUIRE_TLS on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||||
|
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||||
|
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||||
|
# do something in the Anker app while a control read holds the connection
|
||||||
|
# open, and read the frames back out of the log. Off by default: with it on
|
||||||
|
# a charger under a live trigger writes a line every few seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||||
|
# settings on every boot, idempotently. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos. The database and PocketBase's own
|
||||||
|
# backups stay on PB_DATA.
|
||||||
|
PB_S3_ENABLED: "true"
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
# The gateway's service name: a server-to-server call inside the compose
|
||||||
|
# network.
|
||||||
|
PB_S3_ENDPOINT: "http://seaweedfs-s3:8333"
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||||
|
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||||
|
PB_S3_FORCE_PATH_STYLE: "true"
|
||||||
|
ports:
|
||||||
|
# Localhost-only by default (the Web App reaches it over the internal
|
||||||
|
# network). Set API_BIND=0.0.0.0 to expose the API panel — and the
|
||||||
|
# /ocpp/{serial} endpoint chargers dial into — on the network.
|
||||||
|
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
|
||||||
|
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||||
|
# plugin settings included, lives in PocketBase under PB_DATA.
|
||||||
|
healthcheck:
|
||||||
|
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||||
|
# depends_on gate below still works against an older pulled image.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
web-app:
|
||||||
|
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
|
||||||
|
container_name: drivervault-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||||
|
# serving rather than merely started.
|
||||||
|
api-server:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||||
|
# told to dial — to the API Server over the internal network.
|
||||||
|
API_BASE: "http://api-server:8080"
|
||||||
|
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||||
|
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||||
|
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||||
|
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||||
|
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||||
|
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||||
|
ports:
|
||||||
|
# The public front door. Bound on all interfaces so browsers can reach it.
|
||||||
|
- "${WEB_PORT:-8090}:8090"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
|
# Shared by master, volume and filer — the same layout `weed server -dir`
|
||||||
|
# writes, so this file and docker-compose.prod.seaweedfs.yml can swap places
|
||||||
|
# on it.
|
||||||
|
seaweed_data:
|
||||||
|
# The admin UI's own session key and maintenance-task state. Small, and no
|
||||||
|
# part of the object store.
|
||||||
|
seaweed_admin:
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
name: drivervault
|
||||||
|
|
||||||
|
# Production DriverVault stack, with SeaweedFS — pulls prebuilt images from the
|
||||||
|
# registry instead of building from source. Self-contained: one file, no
|
||||||
|
# overlays. Everything an operator needs to set lives in .env.
|
||||||
|
#
|
||||||
|
# 1. cp .env.prod.seaweedfs.example .env (then edit it)
|
||||||
|
# 2. docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||||
|
# 3. docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||||
|
#
|
||||||
|
# This is docker-compose.prod.yml plus an S3 object store: PocketBase keeps its
|
||||||
|
# record files — document scans, service and refill receipts, workshop invoices,
|
||||||
|
# part photos — in a SeaweedFS bucket instead of on the pb_data volume next to
|
||||||
|
# the database. The database and PocketBase's own backups stay on PB_DATA.
|
||||||
|
# Clients cannot tell the difference: an attachment has always been fetched
|
||||||
|
# through the API Server, never from a storage URL.
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
#
|
||||||
|
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||||
|
#
|
||||||
|
# On first boot:
|
||||||
|
# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing).
|
||||||
|
# • the API Server creates any missing collections, reconciles existing ones,
|
||||||
|
# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*.
|
||||||
|
# Both steps are idempotent, so restarts and upgrades are safe.
|
||||||
|
|
||||||
|
services:
|
||||||
|
seaweedfs:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs
|
||||||
|
restart: unless-stopped
|
||||||
|
# One process, four roles: master, volume, filer and the S3 gateway. -dir is
|
||||||
|
# the only state it keeps.
|
||||||
|
command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024
|
||||||
|
environment:
|
||||||
|
# SeaweedFS falls back to these when started without an -s3.config file,
|
||||||
|
# and configuring one identity is what takes the S3 gateway out of its
|
||||||
|
# default allow-anyone mode. The same credentials PocketBase authenticates
|
||||||
|
# with below — one pair to set, in .env.
|
||||||
|
AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
volumes:
|
||||||
|
# Named volume by default; set SEAWEED_DATA to a host path in .env for a
|
||||||
|
# bind mount, exactly as PB_DATA works. Back it up alongside PB_DATA —
|
||||||
|
# from here on the attachments live here, not in the database volume.
|
||||||
|
- "${SEAWEED_DATA:-seaweed_data}:/data"
|
||||||
|
ports:
|
||||||
|
# Loopback only: the stack reaches the gateway over the compose network,
|
||||||
|
# so this is here for `aws s3 ls --endpoint-url http://127.0.0.1:8333` and
|
||||||
|
# nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to expose it, and mean it.
|
||||||
|
- "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
seaweedfs-init:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-init
|
||||||
|
# Runs once and exits. PocketBase never issues a CreateBucket of its own and
|
||||||
|
# SeaweedFS will not conjure one on first upload, so something has to.
|
||||||
|
# Creating a bucket that already exists is a no-op, so every later boot
|
||||||
|
# passes straight through.
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
seaweedfs:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
# `|| true` so a restart is never blocked by the shell's exit status: this
|
||||||
|
# step is best-effort, and a gateway that is genuinely unreachable is
|
||||||
|
# reported by the API Server's own S3 check at boot, with the reason.
|
||||||
|
command:
|
||||||
|
- 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true'
|
||||||
|
|
||||||
|
pocketbase:
|
||||||
|
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
|
||||||
|
container_name: drivervault-pocketbase
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# PocketBase is the process that reads and writes the objects, so the
|
||||||
|
# gateway has to be serving before it is asked to store anything.
|
||||||
|
seaweedfs:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# The superuser is created/updated on boot (the API Server authenticates
|
||||||
|
# with it). This is the only place the first superuser can be created — the
|
||||||
|
# REST API cannot bootstrap it.
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
volumes:
|
||||||
|
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
|
||||||
|
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||||
|
ports:
|
||||||
|
# Bound to localhost by default — the admin UI (/_/) is reachable only on
|
||||||
|
# the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network.
|
||||||
|
- "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
api-server:
|
||||||
|
image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}"
|
||||||
|
container_name: drivervault-api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
|
# The bucket must exist before the bootstrap points PocketBase at it.
|
||||||
|
seaweedfs-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
API_ADDR: ":8080"
|
||||||
|
# Reach PocketBase by its service name on the internal network. Override
|
||||||
|
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||||
|
# this stack — that is also how you make a retarget done from the panel
|
||||||
|
# permanent, since the panel's change lasts only for the container's life.
|
||||||
|
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||||
|
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||||
|
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||||
|
# Probed by the panel status page. This is a server-to-server call inside
|
||||||
|
# the compose network, so the default is the service name — plain
|
||||||
|
# localhost:8090 would resolve to this container itself. Override
|
||||||
|
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||||
|
# permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||||
|
# add collections or fields the server needs, and a stack that skips the
|
||||||
|
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||||
|
# app_settings, the collection holding the plugin settings, which it
|
||||||
|
# creates on demand because it cannot serve the plugin panel without it.
|
||||||
|
# Every other schema change still depends on this flag. Turn it off only
|
||||||
|
# for a database you know already matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||||
|
# reach the server over TLS. Behind a TLS-terminating reverse proxy, set
|
||||||
|
# OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can
|
||||||
|
# reach this port; only drop OCPP_REQUIRE_TLS on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||||
|
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||||
|
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||||
|
# do something in the Anker app while a control read holds the connection
|
||||||
|
# open, and read the frames back out of the log. Off by default: with it on
|
||||||
|
# a charger under a live trigger writes a line every few seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||||
|
# settings on every boot, idempotently. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos. The database and PocketBase's own
|
||||||
|
# backups stay on PB_DATA.
|
||||||
|
PB_S3_ENABLED: "true"
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
# The service name: a server-to-server call inside the compose network.
|
||||||
|
PB_S3_ENDPOINT: "http://seaweedfs:8333"
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||||
|
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||||
|
PB_S3_FORCE_PATH_STYLE: "true"
|
||||||
|
ports:
|
||||||
|
# Localhost-only by default (the Web App reaches it over the internal
|
||||||
|
# network). Set API_BIND=0.0.0.0 to expose the API panel — and the
|
||||||
|
# /ocpp/{serial} endpoint chargers dial into — on the network.
|
||||||
|
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
|
||||||
|
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||||
|
# plugin settings included, lives in PocketBase under PB_DATA.
|
||||||
|
healthcheck:
|
||||||
|
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||||
|
# depends_on gate below still works against an older pulled image.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
web-app:
|
||||||
|
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
|
||||||
|
container_name: drivervault-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||||
|
# serving rather than merely started.
|
||||||
|
api-server:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||||
|
# told to dial — to the API Server over the internal network.
|
||||||
|
API_BASE: "http://api-server:8080"
|
||||||
|
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||||
|
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||||
|
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||||
|
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||||
|
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||||
|
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||||
|
ports:
|
||||||
|
# The public front door. Bound on all interfaces so browsers can reach it.
|
||||||
|
- "${WEB_PORT:-8090}:8090"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
|
seaweed_data:
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
name: drivervault
|
||||||
|
|
||||||
|
# Full DriverVault stack, with external S3: PocketBase (database) + API Server +
|
||||||
|
# Web App, built from source, storing files in an S3 endpoint that already
|
||||||
|
# exists somewhere else. Self-contained — one file, nothing to layer.
|
||||||
|
#
|
||||||
|
# cp .env.s3.example .env (then edit it — PB_S3_* especially)
|
||||||
|
# docker compose -f docker-compose.s3.yml up -d --build
|
||||||
|
#
|
||||||
|
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||||
|
#
|
||||||
|
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
|
||||||
|
# document scans, service and refill receipts, workshop invoices, part photos —
|
||||||
|
# in that bucket instead of on the pb_data volume next to the database. The
|
||||||
|
# database and PocketBase's own backups stay on pb_data. Clients cannot tell the
|
||||||
|
# difference: an attachment has always been fetched through the API Server,
|
||||||
|
# never from a storage URL.
|
||||||
|
#
|
||||||
|
# The bucket must already exist, and nothing here runs the gateway. For a
|
||||||
|
# SeaweedFS that comes up with the stack, use docker-compose.seaweedfs.yml.
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
|
||||||
|
services:
|
||||||
|
pocketbase:
|
||||||
|
build:
|
||||||
|
context: ./pocketbase
|
||||||
|
image: drivervault-pocketbase
|
||||||
|
container_name: drivervault-pocketbase
|
||||||
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||||
|
# Harmless when the endpoint is somewhere else entirely.
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
environment:
|
||||||
|
# Superuser is created/updated on boot so the API Server can authenticate.
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
volumes:
|
||||||
|
- pb_data:/pb/pb_data
|
||||||
|
ports:
|
||||||
|
# Admin UI / API exposed on the host for management (http://host:8070/_/).
|
||||||
|
- "${PB_PORT:-8070}:8070"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
api-server:
|
||||||
|
build:
|
||||||
|
context: ../API Server
|
||||||
|
image: drivervault-api
|
||||||
|
container_name: drivervault-api
|
||||||
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||||
|
# Harmless when the endpoint is somewhere else entirely.
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
API_ADDR: ":8080"
|
||||||
|
# Reach PocketBase by its service name on the internal network. Override
|
||||||
|
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||||
|
# this stack — that is also how you make a retarget done from the panel
|
||||||
|
# permanent, since the panel's change lasts only for the container's life.
|
||||||
|
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||||
|
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||||
|
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||||
|
# Probed by the panel status page. This is a server-to-server call inside
|
||||||
|
# the compose network, so the default is the service name — plain
|
||||||
|
# localhost:8090 would resolve to this container itself. Override
|
||||||
|
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||||
|
# permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||||
|
# Same-origin requests go through the Web App BFF, so CORS is only needed
|
||||||
|
# if the browser ever calls the API Server directly. Default to the web origin.
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars
|
||||||
|
# the collections are still created but no app user is, leaving a stack
|
||||||
|
# you cannot log into.
|
||||||
|
#
|
||||||
|
# Leave the bootstrap ON: a release can add collections or fields the
|
||||||
|
# server needs, and a stack that skips it never gets them. The API Server
|
||||||
|
# self-heals exactly one thing — app_settings, the collection holding the
|
||||||
|
# plugin settings, which it creates on demand because it cannot serve the
|
||||||
|
# plugin panel without it. Every other schema change still depends on this
|
||||||
|
# flag. Turn it off only for a database you know matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||||
|
# connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is
|
||||||
|
# terminated in front of this stack or for local dev on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||||
|
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||||
|
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||||
|
# do something in the Anker app while a control read holds the connection
|
||||||
|
# open, and read the frames back out of the log. Off by default: with it on
|
||||||
|
# a charger under a live trigger writes a line every few seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||||
|
# settings on every boot, idempotently. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos. The database and PocketBase's own
|
||||||
|
# backups stay on pb_data. The bucket must already exist: nothing here
|
||||||
|
# creates it.
|
||||||
|
PB_S3_ENABLED: "true"
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
|
||||||
|
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||||
|
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
|
||||||
|
ports:
|
||||||
|
# Optional direct access to the API Server (and its panel at /); the Web
|
||||||
|
# App reaches it over the internal network, not this host port. Chargers
|
||||||
|
# dialling /ocpp/{serial} also arrive here.
|
||||||
|
- "${API_PORT:-8080}:8080"
|
||||||
|
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||||
|
# plugin settings included, lives in PocketBase under pb_data.
|
||||||
|
healthcheck:
|
||||||
|
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||||
|
# depends_on gate below still works against an older pulled image.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
web-app:
|
||||||
|
build:
|
||||||
|
context: ../Web App
|
||||||
|
args:
|
||||||
|
# Empty -> bundle uses same-origin "/api", which the BFF proxies below.
|
||||||
|
VITE_API_BASE: "${VITE_API_BASE:-}"
|
||||||
|
image: drivervault-web
|
||||||
|
container_name: drivervault-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||||
|
# serving rather than merely started.
|
||||||
|
api-server:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||||
|
# told to dial — to the API Server over the internal network.
|
||||||
|
API_BASE: "http://api-server:8080"
|
||||||
|
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||||
|
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||||
|
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||||
|
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||||
|
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||||
|
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8090}:8090"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
name: drivervault
|
||||||
|
|
||||||
|
# Full DriverVault stack, with SeaweedFS split into its four roles: PocketBase
|
||||||
|
# (database) + API Server + Web App, built from source, plus master, volume,
|
||||||
|
# filer, S3 gateway and the SeaweedFS admin UI as separate containers.
|
||||||
|
# Self-contained — one file, nothing to layer.
|
||||||
|
#
|
||||||
|
# cp .env.seaweedfs.split.example .env (then edit it)
|
||||||
|
# docker compose -f docker-compose.seaweedfs.split.yml up -d --build
|
||||||
|
#
|
||||||
|
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||||
|
#
|
||||||
|
# This is docker-compose.seaweedfs.yml with the storage layer taken apart.
|
||||||
|
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
|
||||||
|
# process; here each is its own container. What that buys:
|
||||||
|
#
|
||||||
|
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
|
||||||
|
# where S3 identities are created and revoked without touching a file;
|
||||||
|
# • per-role restart, upgrade and Prometheus metrics;
|
||||||
|
# • room to add a second volume server later, on this host or another.
|
||||||
|
#
|
||||||
|
# What it costs: five containers instead of one, and five healthchecks to keep
|
||||||
|
# the boot order honest. If none of the above is wanted, use
|
||||||
|
# docker-compose.seaweedfs.yml — the S3 behaviour is identical.
|
||||||
|
#
|
||||||
|
# The on-disk layout is deliberately the same as the single-process file's:
|
||||||
|
# master, volume and filer share one /data mount, exactly as `weed server -dir`
|
||||||
|
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
|
||||||
|
# filename overlap). So the two files are interchangeable on the same volume,
|
||||||
|
# with no migration either way. A *second* volume server would need its own.
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
|
||||||
|
services:
|
||||||
|
# --- SeaweedFS: master -----------------------------------------------------
|
||||||
|
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
|
||||||
|
# the other roles are told to reach it by, so it must be the service name and
|
||||||
|
# not the container IP the process would otherwise detect.
|
||||||
|
seaweedfs-master:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-master
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
|
||||||
|
-volumeSizeLimitMB=1024 -metricsPort=9324
|
||||||
|
volumes:
|
||||||
|
- seaweed_data:/data
|
||||||
|
ports:
|
||||||
|
# Master UI / API. Useful while developing; the admin UI below covers the
|
||||||
|
# same ground with a nicer face.
|
||||||
|
- "${SEAWEED_MASTER_PORT:-9333}:9333"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: volume server ---------------------------------------------
|
||||||
|
# Where the bytes actually land. -max=0 lets it size itself from free disk
|
||||||
|
# rather than the default cap of 8 volumes.
|
||||||
|
seaweedfs-volume:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-volume
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
|
||||||
|
-port=8080 -dir=/data -max=0 -metricsPort=9325
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-master:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- seaweed_data:/data
|
||||||
|
ports:
|
||||||
|
# This port serves file content by file id with NO authentication — the S3
|
||||||
|
# credentials do not apply to it. Publish it only where you would be
|
||||||
|
# willing to publish the bucket itself. Mapped to 8081 on the host because
|
||||||
|
# 8080 there is the API Server.
|
||||||
|
- "${SEAWEED_VOLUME_PORT:-8081}:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: filer ------------------------------------------------------
|
||||||
|
# Gives the flat volume store a directory tree — buckets, object keys — and
|
||||||
|
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
|
||||||
|
# embedded leveldb goes; without it that would be the container's working
|
||||||
|
# directory, and the identities would not survive a recreate.
|
||||||
|
seaweedfs-filer:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-filer
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
|
||||||
|
-port=8888 -defaultStoreDir=/data -metricsPort=9326
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-volume:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- seaweed_data:/data
|
||||||
|
ports:
|
||||||
|
- "${SEAWEED_FILER_PORT:-8888}:8888"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: bucket and identity seed ----------------------------------
|
||||||
|
# Runs once and exits, before the gateway starts. Two jobs:
|
||||||
|
#
|
||||||
|
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
|
||||||
|
# 2. write PocketBase's S3 identity into the filer's IAM store.
|
||||||
|
#
|
||||||
|
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
|
||||||
|
# docker-compose.seaweedfs.yml does. Those env vars are the *lowest* priority
|
||||||
|
# credential source in SeaweedFS: they are read only while the filer's store is
|
||||||
|
# empty, so the first identity added in the admin UI would silently displace
|
||||||
|
# them and lock PocketBase out. Seeding the store the admin UI itself writes
|
||||||
|
# leaves one source of truth, and the key PocketBase uses appears under
|
||||||
|
# Object Store → Users like any other.
|
||||||
|
#
|
||||||
|
# Both commands update in place, so every later boot re-applies the values from
|
||||||
|
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
|
||||||
|
# reaches the gateway.
|
||||||
|
#
|
||||||
|
# The closing grep is the gate: an empty IAM store means the gateway would come
|
||||||
|
# up in its allow-anyone default, so this fails loudly instead and the gateway
|
||||||
|
# below never starts. No `|| true` here, deliberately.
|
||||||
|
seaweedfs-init:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-init
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-filer:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# Passed as env and expanded by the shell inside the container, so the
|
||||||
|
# secret stays out of the container's argv.
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
printf '%s\n' \
|
||||||
|
"s3.bucket.create -name $$PB_S3_BUCKET" \
|
||||||
|
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
|
||||||
|
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
|
||||||
|
echo "s3.configure" \
|
||||||
|
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
|
||||||
|
| grep -q "$$PB_S3_ACCESS_KEY"
|
||||||
|
|
||||||
|
# --- SeaweedFS: S3 gateway -------------------------------------------------
|
||||||
|
# The endpoint PocketBase talks to. No -config file: with only -filer given,
|
||||||
|
# credentials come from the filer's IAM store, which is what lets the admin UI
|
||||||
|
# add and revoke identities without a restart. A config file would take
|
||||||
|
# priority over that store and make the admin UI's users inert.
|
||||||
|
seaweedfs-s3:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-s3
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
|
||||||
|
-metricsPort=9327
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-filer:
|
||||||
|
condition: service_healthy
|
||||||
|
# Never serve before an identity exists — see seaweedfs-init above.
|
||||||
|
seaweedfs-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
ports:
|
||||||
|
# The stack reaches the gateway over the compose network; this is here so
|
||||||
|
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
|
||||||
|
- "${SEAWEED_S3_PORT:-8333}:8333"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8333/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# --- SeaweedFS: admin UI ---------------------------------------------------
|
||||||
|
# http://localhost:23646 — cluster topology, volumes, buckets, maintenance
|
||||||
|
# tasks, and Object Store → Users, where S3 access keys are minted and revoked.
|
||||||
|
# It finds the filer through the master, so -master is all it needs.
|
||||||
|
#
|
||||||
|
# An unauthenticated panel that can mint credentials for the bucket *is* the
|
||||||
|
# bucket, so the password is required rather than defaulted — weed leaves auth
|
||||||
|
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
|
||||||
|
# flag, which keeps it off the process command line. -dataDir persists the
|
||||||
|
# session key and the maintenance-task settings.
|
||||||
|
seaweedfs-admin:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-admin
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
|
||||||
|
-metricsPort=9328
|
||||||
|
depends_on:
|
||||||
|
seaweedfs-master:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
|
||||||
|
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
|
||||||
|
volumes:
|
||||||
|
- seaweed_admin:/data
|
||||||
|
ports:
|
||||||
|
- "${SEAWEED_ADMIN_PORT:-23646}:23646"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
pocketbase:
|
||||||
|
build:
|
||||||
|
context: ./pocketbase
|
||||||
|
image: drivervault-pocketbase
|
||||||
|
container_name: drivervault-pocketbase
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# PocketBase is the process that reads and writes the objects, so the
|
||||||
|
# gateway has to be serving before it is asked to store anything.
|
||||||
|
seaweedfs-s3:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# Superuser is created/updated on boot so the API Server can authenticate.
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
volumes:
|
||||||
|
- pb_data:/pb/pb_data
|
||||||
|
ports:
|
||||||
|
# Admin UI / API exposed on the host for management (http://host:8070/_/).
|
||||||
|
- "${PB_PORT:-8070}:8070"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
api-server:
|
||||||
|
build:
|
||||||
|
context: ../API Server
|
||||||
|
image: drivervault-api
|
||||||
|
container_name: drivervault-api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
|
# The bucket must exist before the bootstrap points PocketBase at it.
|
||||||
|
seaweedfs-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
API_ADDR: ":8080"
|
||||||
|
# Reach PocketBase by its service name on the internal network. Override
|
||||||
|
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||||
|
# this stack — that is also how you make a retarget done from the panel
|
||||||
|
# permanent, since the panel's change lasts only for the container's life.
|
||||||
|
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||||
|
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||||
|
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||||
|
# Probed by the panel status page. This is a server-to-server call inside
|
||||||
|
# the compose network, so the default is the service name — plain
|
||||||
|
# localhost:8090 would resolve to this container itself. Override
|
||||||
|
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||||
|
# permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||||
|
# Same-origin requests go through the Web App BFF, so CORS is only needed
|
||||||
|
# if the browser ever calls the API Server directly. Default to the web origin.
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars
|
||||||
|
# the collections are still created but no app user is, leaving a stack
|
||||||
|
# you cannot log into.
|
||||||
|
#
|
||||||
|
# Leave the bootstrap ON: a release can add collections or fields the
|
||||||
|
# server needs, and a stack that skips it never gets them. The API Server
|
||||||
|
# self-heals exactly one thing — app_settings, the collection holding the
|
||||||
|
# plugin settings, which it creates on demand because it cannot serve the
|
||||||
|
# plugin panel without it. Every other schema change still depends on this
|
||||||
|
# flag. Turn it off only for a database you know matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||||
|
# connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is
|
||||||
|
# terminated in front of this stack or for local dev on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||||
|
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||||
|
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||||
|
# do something in the Anker app while a control read holds the connection
|
||||||
|
# open, and read the frames back out of the log. Off by default: with it on
|
||||||
|
# a charger under a live trigger writes a line every few seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||||
|
# settings on every boot, idempotently. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos. The database and PocketBase's own
|
||||||
|
# backups stay on pb_data.
|
||||||
|
PB_S3_ENABLED: "true"
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
# The gateway's service name: a server-to-server call inside the compose
|
||||||
|
# network.
|
||||||
|
PB_S3_ENDPOINT: "http://seaweedfs-s3:8333"
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||||
|
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||||
|
PB_S3_FORCE_PATH_STYLE: "true"
|
||||||
|
ports:
|
||||||
|
# Optional direct access to the API Server (and its panel at /); the Web
|
||||||
|
# App reaches it over the internal network, not this host port. Chargers
|
||||||
|
# dialling /ocpp/{serial} also arrive here.
|
||||||
|
- "${API_PORT:-8080}:8080"
|
||||||
|
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||||
|
# plugin settings included, lives in PocketBase under pb_data.
|
||||||
|
healthcheck:
|
||||||
|
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||||
|
# depends_on gate below still works against an older pulled image.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
web-app:
|
||||||
|
build:
|
||||||
|
context: ../Web App
|
||||||
|
args:
|
||||||
|
# Empty -> bundle uses same-origin "/api", which the BFF proxies below.
|
||||||
|
VITE_API_BASE: "${VITE_API_BASE:-}"
|
||||||
|
image: drivervault-web
|
||||||
|
container_name: drivervault-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||||
|
# serving rather than merely started.
|
||||||
|
api-server:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||||
|
# told to dial — to the API Server over the internal network.
|
||||||
|
API_BASE: "http://api-server:8080"
|
||||||
|
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||||
|
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||||
|
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||||
|
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||||
|
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||||
|
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8090}:8090"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
|
# Shared by master, volume and filer — the same layout `weed server -dir`
|
||||||
|
# writes, so this file and docker-compose.seaweedfs.yml can swap places on it.
|
||||||
|
seaweed_data:
|
||||||
|
# The admin UI's own session key and maintenance-task state. Small, and no
|
||||||
|
# part of the object store.
|
||||||
|
seaweed_admin:
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
name: drivervault
|
||||||
|
|
||||||
|
# Full DriverVault stack, with SeaweedFS: PocketBase (database) + API Server +
|
||||||
|
# Web App, built from source, plus an S3 object store. Self-contained — one
|
||||||
|
# file, nothing to layer.
|
||||||
|
#
|
||||||
|
# cp .env.seaweedfs.example .env (then edit it)
|
||||||
|
# docker compose -f docker-compose.seaweedfs.yml up -d --build
|
||||||
|
#
|
||||||
|
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||||
|
#
|
||||||
|
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
|
||||||
|
# document scans, service and refill receipts, workshop invoices, part photos —
|
||||||
|
# in a SeaweedFS bucket instead of on the pb_data volume next to the database.
|
||||||
|
# The database and PocketBase's own backups stay on pb_data. Clients cannot tell
|
||||||
|
# the difference: an attachment has always been fetched through the API Server,
|
||||||
|
# never from a storage URL.
|
||||||
|
#
|
||||||
|
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||||
|
# NOT copy existing files into the bucket. See README.md.
|
||||||
|
|
||||||
|
services:
|
||||||
|
seaweedfs:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs
|
||||||
|
restart: unless-stopped
|
||||||
|
# One process, four roles: master, volume, filer and the S3 gateway. -dir is
|
||||||
|
# the only state it keeps.
|
||||||
|
command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024
|
||||||
|
environment:
|
||||||
|
# SeaweedFS falls back to these when started without an -s3.config file,
|
||||||
|
# and configuring one identity is what takes the S3 gateway out of its
|
||||||
|
# default allow-anyone mode. The same credentials PocketBase authenticates
|
||||||
|
# with below — one pair to set, in .env.
|
||||||
|
AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||||
|
AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||||
|
volumes:
|
||||||
|
# From here on the attachments live here, not on pb_data.
|
||||||
|
- seaweed_data:/data
|
||||||
|
ports:
|
||||||
|
# The stack reaches the gateway over the compose network; this is here so
|
||||||
|
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
|
||||||
|
- "${SEAWEED_S3_PORT:-8333}:8333"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
seaweedfs-init:
|
||||||
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||||
|
container_name: drivervault-seaweedfs-init
|
||||||
|
# Runs once and exits. PocketBase never issues a CreateBucket of its own and
|
||||||
|
# SeaweedFS will not conjure one on first upload, so something has to.
|
||||||
|
# Creating a bucket that already exists is a no-op, so every later boot
|
||||||
|
# passes straight through.
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
seaweedfs:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
# `|| true` so a restart is never blocked by the shell's exit status: this
|
||||||
|
# step is best-effort, and a gateway that is genuinely unreachable is
|
||||||
|
# reported by the API Server's own S3 check at boot, with the reason.
|
||||||
|
command:
|
||||||
|
- 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true'
|
||||||
|
|
||||||
|
pocketbase:
|
||||||
|
build:
|
||||||
|
context: ./pocketbase
|
||||||
|
image: drivervault-pocketbase
|
||||||
|
container_name: drivervault-pocketbase
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# PocketBase is the process that reads and writes the objects, so the
|
||||||
|
# gateway has to be serving before it is asked to store anything.
|
||||||
|
seaweedfs:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# Superuser is created/updated on boot so the API Server can authenticate.
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
volumes:
|
||||||
|
- pb_data:/pb/pb_data
|
||||||
|
ports:
|
||||||
|
# Admin UI / API exposed on the host for management (http://host:8070/_/).
|
||||||
|
- "${PB_PORT:-8070}:8070"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
api-server:
|
||||||
|
build:
|
||||||
|
context: ../API Server
|
||||||
|
image: drivervault-api
|
||||||
|
container_name: drivervault-api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
|
# The bucket must exist before the bootstrap points PocketBase at it.
|
||||||
|
seaweedfs-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
API_ADDR: ":8080"
|
||||||
|
# Reach PocketBase by its service name on the internal network. Override
|
||||||
|
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||||
|
# this stack — that is also how you make a retarget done from the panel
|
||||||
|
# permanent, since the panel's change lasts only for the container's life.
|
||||||
|
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||||
|
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||||
|
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||||
|
# Probed by the panel status page. This is a server-to-server call inside
|
||||||
|
# the compose network, so the default is the service name — plain
|
||||||
|
# localhost:8090 would resolve to this container itself. Override
|
||||||
|
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||||
|
# permanent; the panel alone only holds it for the container's life.
|
||||||
|
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||||
|
# Same-origin requests go through the Web App BFF, so CORS is only needed
|
||||||
|
# if the browser ever calls the API Server directly. Default to the web origin.
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars
|
||||||
|
# the collections are still created but no app user is, leaving a stack
|
||||||
|
# you cannot log into.
|
||||||
|
#
|
||||||
|
# Leave the bootstrap ON: a release can add collections or fields the
|
||||||
|
# server needs, and a stack that skips it never gets them. The API Server
|
||||||
|
# self-heals exactly one thing — app_settings, the collection holding the
|
||||||
|
# plugin settings, which it creates on demand because it cannot serve the
|
||||||
|
# plugin panel without it. Every other schema change still depends on this
|
||||||
|
# flag. Turn it off only for a database you know matches the release.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||||
|
# connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is
|
||||||
|
# terminated in front of this stack or for local dev on a trusted network.
|
||||||
|
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||||
|
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||||
|
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||||
|
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||||
|
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||||
|
# do something in the Anker app while a control read holds the connection
|
||||||
|
# open, and read the frames back out of the log. Off by default: with it on
|
||||||
|
# a charger under a live trigger writes a line every few seconds.
|
||||||
|
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||||
|
# --- File storage --------------------------------------------------
|
||||||
|
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||||
|
# settings on every boot, idempotently. Only record files move — scans,
|
||||||
|
# receipts, invoices, part photos. The database and PocketBase's own
|
||||||
|
# backups stay on pb_data.
|
||||||
|
PB_S3_ENABLED: "true"
|
||||||
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||||
|
# The service name: a server-to-server call inside the compose network.
|
||||||
|
PB_S3_ENDPOINT: "http://seaweedfs:8333"
|
||||||
|
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||||
|
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||||
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||||
|
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||||
|
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||||
|
PB_S3_FORCE_PATH_STYLE: "true"
|
||||||
|
ports:
|
||||||
|
# Optional direct access to the API Server (and its panel at /); the Web
|
||||||
|
# App reaches it over the internal network, not this host port. Chargers
|
||||||
|
# dialling /ocpp/{serial} also arrive here.
|
||||||
|
- "${API_PORT:-8080}:8080"
|
||||||
|
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||||
|
# plugin settings included, lives in PocketBase under pb_data.
|
||||||
|
healthcheck:
|
||||||
|
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||||
|
# depends_on gate below still works against an older pulled image.
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
web-app:
|
||||||
|
build:
|
||||||
|
context: ../Web App
|
||||||
|
args:
|
||||||
|
# Empty -> bundle uses same-origin "/api", which the BFF proxies below.
|
||||||
|
VITE_API_BASE: "${VITE_API_BASE:-}"
|
||||||
|
image: drivervault-web
|
||||||
|
container_name: drivervault-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||||
|
# serving rather than merely started.
|
||||||
|
api-server:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||||
|
# told to dial — to the API Server over the internal network.
|
||||||
|
API_BASE: "http://api-server:8080"
|
||||||
|
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||||
|
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||||
|
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||||
|
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||||
|
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||||
|
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8090}:8090"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
|
seaweed_data:
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
name: drivervault
|
|
||||||
|
|
||||||
# TLS overlay — put a public hostname and a real certificate in front of the
|
|
||||||
# stack. Layer it on top of either base file:
|
|
||||||
#
|
|
||||||
# docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
|
|
||||||
#
|
|
||||||
# What it changes:
|
|
||||||
# • Caddy terminates TLS on :443 and proxies everything to the Web App BFF,
|
|
||||||
# which already carries /api/ and /ocpp/ through to the API Server. One
|
|
||||||
# hostname serves browsers and chargers alike.
|
|
||||||
# • The charger endpoint the panel hands out becomes wss://$DV_DOMAIN/ocpp/…,
|
|
||||||
# so OCPP_REQUIRE_TLS goes back on and the control token stops crossing the
|
|
||||||
# network in the clear.
|
|
||||||
#
|
|
||||||
# Requirements, none of which this file can arrange for you:
|
|
||||||
# • DV_DOMAIN resolves to this host from the public internet, and ports 80 and
|
|
||||||
# 443 reach it (Caddy needs :80 for the ACME challenge, and keeps it for the
|
|
||||||
# redirect afterwards).
|
|
||||||
# • The charger can resolve DV_DOMAIN too. On a LAN behind NAT that usually
|
|
||||||
# means hairpin NAT or a split-DNS entry pointing the name at 10.2.1.10 —
|
|
||||||
# otherwise the charger looks up a public address it cannot route to.
|
|
||||||
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
image: "${CADDY_IMAGE:-caddy:2-alpine}"
|
|
||||||
container_name: drivervault-caddy
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
web-app:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
DV_DOMAIN: "${DV_DOMAIN:?set DV_DOMAIN in .env}"
|
|
||||||
# Let's Encrypt sends expiry warnings here if renewal ever stops working.
|
|
||||||
DV_ACME_EMAIL: "${DV_ACME_EMAIL:?set DV_ACME_EMAIL in .env}"
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
||||||
# Certificates live here. Keep the volume: wiping it re-issues on next
|
|
||||||
# boot and Let's Encrypt rate-limits that.
|
|
||||||
- caddy_data:/data
|
|
||||||
- caddy_config:/config
|
|
||||||
|
|
||||||
api-server:
|
|
||||||
environment:
|
|
||||||
# Derived from the one hostname, so there is a single thing to set.
|
|
||||||
OCPP_REQUIRE_TLS: "true"
|
|
||||||
OCPP_PUBLIC_URL: "wss://${DV_DOMAIN}"
|
|
||||||
CORS_ALLOW_ORIGINS: "https://${DV_DOMAIN}"
|
|
||||||
|
|
||||||
web-app:
|
|
||||||
environment:
|
|
||||||
# Caddy terminates TLS and says so in X-Forwarded-Proto. Believe it —
|
|
||||||
# that header is now set by the proxy in front, not by whoever dialed in.
|
|
||||||
TRUST_FORWARDED_PROTO: "true"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
caddy_data:
|
|
||||||
caddy_config:
|
|
||||||
+65
-27
@@ -92,25 +92,55 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an
|
|||||||
workshop visit, refill, charge, document and part (PDF or image, up to 10MB). Picked
|
workshop visit, refill, charge, document and part (PDF or image, up to 10MB). Picked
|
||||||
with `file_picker`, fetched back through the API Server — never a public URL —
|
with `file_picker`, fetched back through the API Server — never a public URL —
|
||||||
and opened with the phone's own viewer via `open_filex`.
|
and opened with the phone's own viewer via `open_filex`.
|
||||||
- **Charging** — mirrors the web `Charging.vue`, split into two tabs. **Public**
|
- **Charging** — mirrors the web `Charging.vue`, split into three tabs.
|
||||||
is a discovery map with a demo session and nearby stations: presentational
|
**Public** is a discovery map with a demo session and nearby stations:
|
||||||
placeholders, because there is no public-charging API yet (same as the web).
|
presentational placeholders, because there is no public-charging API yet (same
|
||||||
**Home** is not a placeholder. It lists the chargers you own — imported from a
|
as the web). **Home** is not a placeholder. It lists the chargers you own —
|
||||||
service you connected, the same move the garage makes for a car — and above
|
imported from a service you connected, the same move the garage makes for a
|
||||||
them four cards about the one you picked: **control** (start/stop, a current
|
car — and above them six cards about the one you picked:
|
||||||
limit, boost or reset depending on the transport), **connection** (which
|
- **control** — start/stop, boost, skip a start delay, reboot, and the current
|
||||||
charger, and either its serial or its address on your network), **readings**
|
limit on the one transport that has nowhere else to put it. It opens with
|
||||||
(everything the charger reports over Modbus — per-phase power, its settings,
|
the charger it acts on, picture and name, because the buttons drive whichever
|
||||||
what it is, and any alarm), and **information** (everything the record holds,
|
serial is in force and that is not always the record highlighted below.
|
||||||
with the service's live view of whether it is reachable). Control needs a mode
|
- **RFID cards** — who may start a charge without a phone. The list the
|
||||||
picked under Settings → Integrations — Modbus TCP over the local network, or
|
account holds, a card added by number or by holding it against the charger's
|
||||||
Own/Proxy CSMS over OCPP — but the information card stands without one.
|
own reader (the reader opens for twenty seconds and the number arrives on its
|
||||||
Each card folds away, remembered per device; the tabs and the cards rearrange
|
own), and the charger's own list read back from the device, which is the half
|
||||||
from the ⇅ button in the app bar, and that arrangement is saved on your
|
that actually decides whether a card opens it. When the two disagree the card
|
||||||
profile, so it follows the account the way the garage order does.
|
says which list each card is missing from.
|
||||||
|
- **charger settings** — what the charger is set to, written back. Over Modbus
|
||||||
|
that is four registers (the current limit, phase count, boost, the control
|
||||||
|
timeout); over the Anker cloud it is the charger's whole settings group, in
|
||||||
|
sections — charging, schedule, load balancing, solar, panel and light, local
|
||||||
|
network — one write per section, because the charger takes a command whole.
|
||||||
|
- **connection** — which charger, and either its serial or its address on your
|
||||||
|
network.
|
||||||
|
- **readings** — everything the charger reports: per-phase power, its
|
||||||
|
settings, what it is, and any alarm.
|
||||||
|
- **information** — everything the record holds, with the service's live view
|
||||||
|
of whether it is reachable, the fields the service sent under its own names,
|
||||||
|
and each of the account's per-charger views read a record at a time.
|
||||||
|
|
||||||
|
Control needs a mode picked under Settings → Integrations — Modbus TCP over
|
||||||
|
the local network, the Anker cloud, or Own/Proxy CSMS over OCPP — but the
|
||||||
|
information and RFID cards stand without one. Each card folds away, remembered
|
||||||
|
per device; the tabs and the cards rearrange from the ⇅ button in the app bar,
|
||||||
|
and that arrangement is saved on your profile, so it follows the account the
|
||||||
|
way the garage order does. A card added after you arranged yours appears beside
|
||||||
|
the neighbour it was written to sit under, rather than at the bottom.
|
||||||
|
|
||||||
|
**Scheduler** is the third tab: one list of charging tasks covering every
|
||||||
|
charger you own, where the charger's own cloud schedule is one window inside
|
||||||
|
one box. A task is a whole flow — start at 23:00, cap to 10 A at 01:00, stop at
|
||||||
|
06:30 — on the days you pick and the chargers you pick, named once and switched
|
||||||
|
on and off as one. Naming no charger means every charger you own, including the
|
||||||
|
ones you import later. The clock is the server's, so a task fires whether or
|
||||||
|
not the app is open; each row says how its last firing went, and any step can
|
||||||
|
be fired now to find out whether it will reach the charger before the night it
|
||||||
|
matters.
|
||||||
- **Settings** — account (name / email verification / password), appearance
|
- **Settings** — account (name / email verification / password), appearance
|
||||||
(theme + dark mode, **language**, **region**, date format, **currency**, font
|
(theme + dark mode, **language**, **region**, date format, **time format**,
|
||||||
size), profile (avatar via `image_picker`, bio), **integrations** (Toyota,
|
**first day of the week**, **currency**, font size), profile (avatar via `image_picker`, bio), **integrations** (Toyota,
|
||||||
Anker Solix), **Security** (biometric toggle), **Organization** (create your
|
Anker Solix), **Security** (biometric toggle), **Organization** (create your
|
||||||
own — which makes you its admin — or rename/delete the one you administer),
|
own — which makes you its admin — or rename/delete the one you administer),
|
||||||
**data export/import**, and the account-deletion state machine. Export writes
|
**data export/import**, and the account-deletion state machine. Export writes
|
||||||
@@ -265,15 +295,23 @@ Running it on your own car:
|
|||||||
- The service is declared under `androidx.car.app.category.IOT`. It is the
|
- The service is declared under `androidx.car.app.category.IOT`. It is the
|
||||||
closest of the categories the library defines — DriverVault is a garage, not a
|
closest of the categories the library defines — DriverVault is a garage, not a
|
||||||
map, a media player or a parking service — and a Play Store submission would
|
map, a media player or a parking service — and a Play Store submission would
|
||||||
be reviewed against it. This build is sideloaded, so what matters instead is
|
be reviewed against it. Getting it onto a car at all is the next line.
|
||||||
the next line.
|
- **A sideloaded build never appears on a real head unit**, and no setting on the
|
||||||
- Android Auto refuses apps it did not get from the Play Store until you tell it
|
phone changes that. Android Auto's **Unknown sources** developer option is the
|
||||||
otherwise: in the **Android Auto** settings on the phone, tap the version ten
|
one everybody reaches for here, but it covers media, messaging-notification and
|
||||||
times to unlock **Developer settings**, then turn on **Unknown sources**.
|
parked apps only — [Google's testing
|
||||||
- To try it without a car, run Google's
|
guide](https://developer.android.com/training/cars/testing) says in as many
|
||||||
[Desktop Head Unit](https://developer.android.com/training/cars/testing/dhu).
|
words that it does not apply to apps built with the Car App Library. A car will
|
||||||
A debug build accepts any host so the DHU can connect; a release build only
|
list DriverVault only if the APK was installed *from Play*: an internal testing
|
||||||
accepts the signed hosts the library ships an allowlist for.
|
track, or internal app sharing, is enough — no public listing, no review.
|
||||||
|
- To try it without Play — which is how these screens were written — run Google's
|
||||||
|
[Desktop Head Unit](https://developer.android.com/training/cars/testing/dhu),
|
||||||
|
which does take a sideloaded build. In the **Android Auto** settings on the
|
||||||
|
phone, tap the version ten times to unlock **Developer settings**, then
|
||||||
|
**Start head unit server**; on the machine, `adb forward tcp:5277 tcp:5277` and
|
||||||
|
run `desktop-head-unit`. Install the **debug** APK for this: a debug build
|
||||||
|
accepts any host so the DHU can connect, while a release build only accepts the
|
||||||
|
signed hosts the library ships an allowlist for.
|
||||||
|
|
||||||
## Configure the API endpoint
|
## Configure the API endpoint
|
||||||
|
|
||||||
|
|||||||
@@ -50,9 +50,13 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.car.application"
|
android:name="com.google.android.gms.car.application"
|
||||||
android:resource="@xml/automotive_app_desc"/>
|
android:resource="@xml/automotive_app_desc"/>
|
||||||
|
<!-- Level 2, not 1: GarageScreen asks the host for ConstraintManager to
|
||||||
|
size its list, and that service arrived at level 2. Every shipping
|
||||||
|
host is far above this; the floor just has to not claim less than
|
||||||
|
the screens actually use. -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="androidx.car.app.minCarApiLevel"
|
android:name="androidx.car.app.minCarApiLevel"
|
||||||
android:value="1"/>
|
android:value="2"/>
|
||||||
<service
|
<service
|
||||||
android:name=".car.DriverVaultCarAppService"
|
android:name=".car.DriverVaultCarAppService"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ class CarStrings private constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val BASE = "en"
|
private const val BASE = "en"
|
||||||
private val PLACEHOLDER = Regex("""\{(\w+)}""")
|
// Both braces escaped, as in i18n.dart's twin of this pattern.
|
||||||
|
// Android compiles regexes through ICU rather than java.util.regex,
|
||||||
|
// and ICU refuses a closing brace that opens no quantifier — a lone
|
||||||
|
// "}" here throws PatternSyntaxException the first time CarStrings
|
||||||
|
// is touched, which on a head unit is a screen that never draws.
|
||||||
|
private val PLACEHOLDER = Regex("""\{(\w+)\}""")
|
||||||
|
|
||||||
/** Loads the files for the account's language, falling back to English. */
|
/** Loads the files for the account's language, falling back to English. */
|
||||||
fun of(context: Context): CarStrings {
|
fun of(context: Context): CarStrings {
|
||||||
|
|||||||
@@ -112,7 +112,8 @@
|
|||||||
"title": "Ladere i nærheden",
|
"title": "Ladere i nærheden",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"public": "Offentlige ladere",
|
"public": "Offentlige ladere",
|
||||||
"home": "Hjemmeladere"
|
"home": "Hjemmeladere",
|
||||||
|
"scheduler": "Planlægning af hjemmelader"
|
||||||
},
|
},
|
||||||
"arrange": {
|
"arrange": {
|
||||||
"title": "Indret opladningssiden",
|
"title": "Indret opladningssiden",
|
||||||
@@ -281,7 +282,29 @@
|
|||||||
"ocpp1": "Forbinder",
|
"ocpp1": "Forbinder",
|
||||||
"ocpp2": "Forbundet",
|
"ocpp2": "Forbundet",
|
||||||
"mqtt0": "Ikke forbundet",
|
"mqtt0": "Ikke forbundet",
|
||||||
"mqtt1": "Forbundet"
|
"mqtt1": "Forbundet",
|
||||||
|
"settingsTitle": "Laderindstillinger",
|
||||||
|
"apply": "Anvend",
|
||||||
|
"turnOn": "Slå til",
|
||||||
|
"turnOff": "Slå fra",
|
||||||
|
"limitFloorHint": "{amps} A er bunden — derunder holder laderen pause i stedet for at lade langsomt.",
|
||||||
|
"boostHint": "Kun den aktuelle session; laderen rydder det, når sessionen slutter.",
|
||||||
|
"timeoutHint": "Mindst {n} sekunder. Laderen falder tilbage til sin egen strategi, hvis intet skriver inden for tiden.",
|
||||||
|
"settingsReported": "Rapporteret, kan ikke indstilles",
|
||||||
|
"settingsReportedHint": "Laderen rapporterer disse; Modbus-kortet har intet register til at skrive dem. Ret dem i Anker-appen.",
|
||||||
|
"blockCharging": "Opladning",
|
||||||
|
"blockSchedule": "Tidsplan",
|
||||||
|
"blockBalancing": "Belastningsbalancering",
|
||||||
|
"blockSolar": "Sol",
|
||||||
|
"blockPanel": "Panel og lys",
|
||||||
|
"blockLocal": "Lokalt netværk",
|
||||||
|
"windowStart": "Start",
|
||||||
|
"windowEnd": "Slut",
|
||||||
|
"reset": "Fortryd",
|
||||||
|
"cloudSettingsHint": "Laderens egne indstillinger, skrevet via Anker-skyen. Et afsnit er én kommando til laderen, så dets felter anvendes samlet.",
|
||||||
|
"cloudSettingsReportedHint": "Laderen rapporterer disse; ingen kommando skriver dem. Hvad de to tilstande og flaget vælger, er udokumenteret, så de vises som de tal, de er.",
|
||||||
|
"modbusOffWarning": "Med Modbus TCP-serveren slået fra svarer laderen ikke længere på det lokale netværk, og Modbus-styringstilstanden har intet at ringe op.",
|
||||||
|
"chargingStatus": "Ladestatus"
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Laderoplysninger",
|
"title": "Laderoplysninger",
|
||||||
@@ -346,7 +369,46 @@
|
|||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"refresh": "Opdater",
|
"refresh": "Opdater",
|
||||||
"rawTitle": "Som tjenesten melder det",
|
"rawTitle": "Som tjenesten melder det",
|
||||||
"rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt."
|
"rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt.",
|
||||||
|
"nickname": "Kaldenavn",
|
||||||
|
"productCode": "Produktkode",
|
||||||
|
"deviceType": "Enhedstype",
|
||||||
|
"charging": "Oplader",
|
||||||
|
"statusCode": "Statuskode",
|
||||||
|
"ocppLink": "OCPP-forbindelse",
|
||||||
|
"wifiOnline": "Wi-Fi forbundet",
|
||||||
|
"bleId": "Bluetooth-id",
|
||||||
|
"blePassword": "Bluetooth-parringskode",
|
||||||
|
"ownerId": "Ejer-id",
|
||||||
|
"fields": {
|
||||||
|
"email": "E-mail",
|
||||||
|
"serial": "Serienummer",
|
||||||
|
"memberId": "Medlems-id",
|
||||||
|
"memberType": "Medlemstype",
|
||||||
|
"userId": "Bruger-id",
|
||||||
|
"status": "Status",
|
||||||
|
"inviteLimit": "Invitationsgrænse",
|
||||||
|
"sessions": "Sessioner",
|
||||||
|
"chargeTime": "Opladningstid",
|
||||||
|
"energy": "Opladet energi",
|
||||||
|
"co2Saved": "CO2 sparet",
|
||||||
|
"cost": "Omkostning",
|
||||||
|
"costSaved": "Sparet beløb",
|
||||||
|
"currency": "Valuta",
|
||||||
|
"mileage": "Kilometertal",
|
||||||
|
"page": "Side",
|
||||||
|
"perPage": "Pr. side",
|
||||||
|
"records": "Poster",
|
||||||
|
"from": "Fra",
|
||||||
|
"source": "Kilde",
|
||||||
|
"timeZone": "Tidszone",
|
||||||
|
"updated": "Opdateret",
|
||||||
|
"added": "Tilføjet",
|
||||||
|
"address": "Adresse",
|
||||||
|
"name": "Navn",
|
||||||
|
"cardName": "Kortnavn",
|
||||||
|
"cardNumber": "Kortnummer"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"count": {
|
"count": {
|
||||||
@@ -369,6 +431,60 @@
|
|||||||
},
|
},
|
||||||
"free": "{avail} af {total} ledige",
|
"free": "{avail} af {total} ledige",
|
||||||
"full": "Optaget"
|
"full": "Optaget"
|
||||||
|
},
|
||||||
|
"rfid": {
|
||||||
|
"title": "RFID-kortindstillinger",
|
||||||
|
"none": "Ingen kort er godkendt til denne lader.",
|
||||||
|
"unsupported": "Tjenesten, som denne lader kommer fra, rapporterer ikke RFID-kort.",
|
||||||
|
"add": "Tilføj kort",
|
||||||
|
"tap": "Hold kortet mod laderen",
|
||||||
|
"tapping": "Hold kortet mod læseren… {n}s",
|
||||||
|
"tapHint": "Læseren er åben. Hold kortet mod laderen.",
|
||||||
|
"tapSave": "Hold kortet mod laderen, og tilføj det",
|
||||||
|
"tapSaveHint": "Tilføjer kortet, så snart det holdes mod læseren, med navnet RFID og kortets sidste fire cifre.",
|
||||||
|
"tapNone": "Der blev ikke holdt et kort mod læseren, før den lukkede.",
|
||||||
|
"addTitle": "Tilføj et kort",
|
||||||
|
"remove": "Fjern",
|
||||||
|
"removeConfirm": "Fjern {name} fra denne lader?",
|
||||||
|
"numberPlaceholder": "Kortnummer",
|
||||||
|
"namePlaceholder": "Navn (valgfrit)",
|
||||||
|
"notAdded": "Tjenesten tog imod anmodningen, men kortet er ikke på laderen. Kontrollér nummeret, og prøv igen.",
|
||||||
|
"notRemoved": "Tjenesten tog imod anmodningen, men kortet er stadig på laderen.",
|
||||||
|
"readCharger": "Læs laderens egen liste",
|
||||||
|
"chargerTitle": "På selve laderen",
|
||||||
|
"chargerNone": "Laderen har ingen kort.",
|
||||||
|
"chargerHint": "Spurgt laderen, ikke kontoen. Den svarer kun med numre — et korts navn hører til på kontoen.",
|
||||||
|
"driftTitle": "De to lister er ikke enige",
|
||||||
|
"onlyOnCharger": "Åbner laderen, men findes ikke på kontoen: {cards}",
|
||||||
|
"onlyOnAccount": "På kontoen, men ikke på laderen, så det åbner den ikke: {cards}",
|
||||||
|
"inferred": "Anker dokumenterer hverken tilføjelse eller fjernelse. DriverVault udleder anmodningen af de felter, kortlisten svarer med, og læser derefter listen igen — det, du ser ovenfor, er det, kontoen har."
|
||||||
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"title": "Ladeopgaver",
|
||||||
|
"subtitle": "Én plan for alle dine ladere. En opgave er et forløb — start, grænse, stop — der kører på de dage du vælger, på de ladere du vælger.",
|
||||||
|
"add": "Ny opgave",
|
||||||
|
"empty": "Ingen opgaver endnu. En opgave er et helt forløb: start kl. 23:00, begræns til 10 A kl. 01:00, stop kl. 06:30.",
|
||||||
|
"needCharger": "Importér først en lader under Hjemmeladere — en opgave skal have noget at handle på.",
|
||||||
|
"serverHint": "Opgaverne kører på serveren, så de udføres uanset om denne side er åben. Tidspunkter læses i den tidszone, du skrev dem i.",
|
||||||
|
"allChargers": "Alle ladere",
|
||||||
|
"missingChargers": "Ingen lader på kontoen længere",
|
||||||
|
"everyDay": "Hver dag",
|
||||||
|
"runNow": "Kør nu",
|
||||||
|
"running": "Sender…",
|
||||||
|
"lastRun": "Sidst kørt {when}",
|
||||||
|
"noResult": "intet resultat registreret",
|
||||||
|
"toggleHint": "Om uret udløser denne opgave.",
|
||||||
|
"removeConfirm": "Slet opgaven “{name}”?",
|
||||||
|
"taskCount": {
|
||||||
|
"one": "{n} opgave",
|
||||||
|
"other": "{n} opgaver"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"start": "Start opladning",
|
||||||
|
"stop": "Stop opladning",
|
||||||
|
"limit": "Sæt strømgrænse",
|
||||||
|
"boost": "Boost sessionen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -415,7 +531,17 @@
|
|||||||
"fontSize": "Skriftstørrelse",
|
"fontSize": "Skriftstørrelse",
|
||||||
"fontSmall": "Lille",
|
"fontSmall": "Lille",
|
||||||
"fontMedium": "Mellem",
|
"fontMedium": "Mellem",
|
||||||
"fontLarge": "Stor"
|
"fontLarge": "Stor",
|
||||||
|
"timeFormat": "Tidsformat",
|
||||||
|
"timeAuto": "Følg regionen",
|
||||||
|
"time24": "24-timers",
|
||||||
|
"time12": "12-timers",
|
||||||
|
"timeExample": "Eksempel: {example}",
|
||||||
|
"weekStart": "Første dag i ugen",
|
||||||
|
"weekAuto": "Følg regionen",
|
||||||
|
"weekMonday": "Mandag",
|
||||||
|
"weekSunday": "Søndag",
|
||||||
|
"weekExample": "Eksempel: {example}"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
@@ -535,6 +661,8 @@
|
|||||||
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
|
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
|
||||||
"controlMode": "Styringstilstand",
|
"controlMode": "Styringstilstand",
|
||||||
"controlModeHint": "Hvordan DriverVault styrer laderen.",
|
"controlModeHint": "Hvordan DriverVault styrer laderen.",
|
||||||
|
"controlModesHidden": "Skjul for dine brugere",
|
||||||
|
"controlModesHiddenHint": "Tilstande du markerer her forsvinder fra dine brugeres liste og træder ikke længere i kraft for dem. Kun overvågning er altid tilgængelig.",
|
||||||
"controlOff": "Fra (kun overvågning)",
|
"controlOff": "Fra (kun overvågning)",
|
||||||
"controlOwn": "Eget CSMS (fuld styring)",
|
"controlOwn": "Eget CSMS (fuld styring)",
|
||||||
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
|
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
|
||||||
@@ -1167,6 +1295,25 @@
|
|||||||
"alreadyImported": "Allerede importeret",
|
"alreadyImported": "Allerede importeret",
|
||||||
"name": "Ladernavn",
|
"name": "Ladernavn",
|
||||||
"submit": "Importer lader"
|
"submit": "Importer lader"
|
||||||
|
},
|
||||||
|
"chargingTask": {
|
||||||
|
"title": "Ny ladeopgave",
|
||||||
|
"editTitle": "Rediger ladeopgave",
|
||||||
|
"name": "Navn",
|
||||||
|
"namePlaceholder": "Nattakst",
|
||||||
|
"time": "Kl.",
|
||||||
|
"flow": "Forløbet",
|
||||||
|
"flowHint": "Hvert trin udføres på sit eget tidspunkt, hver dag opgaven kører. En hel nat er én opgave: start kl. 23:00, stop kl. 06:30.",
|
||||||
|
"addStep": "+ Tilføj et trin",
|
||||||
|
"removeStep": "Fjern dette trin",
|
||||||
|
"amps": "Strømgrænse",
|
||||||
|
"ampsHint": "6 A er bundgrænsen — derunder sætter laderen på pause i stedet for at lade langsomt.",
|
||||||
|
"chargers": "På disse ladere",
|
||||||
|
"allChargers": "Alle mine ladere",
|
||||||
|
"allChargersHint": "Inklusive ladere du importerer senere.",
|
||||||
|
"noChargers": "Ingen ladere på kontoen endnu.",
|
||||||
|
"days": "På disse dage",
|
||||||
|
"everyDay": "Hver dag"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"enums": {
|
"enums": {
|
||||||
|
|||||||
@@ -112,7 +112,8 @@
|
|||||||
"title": "Nearby chargers",
|
"title": "Nearby chargers",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"public": "Public chargers",
|
"public": "Public chargers",
|
||||||
"home": "Home chargers"
|
"home": "Home chargers",
|
||||||
|
"scheduler": "Home charger scheduler"
|
||||||
},
|
},
|
||||||
"arrange": {
|
"arrange": {
|
||||||
"title": "Arrange the charging page",
|
"title": "Arrange the charging page",
|
||||||
@@ -281,7 +282,29 @@
|
|||||||
"ocpp1": "Connecting",
|
"ocpp1": "Connecting",
|
||||||
"ocpp2": "Connected",
|
"ocpp2": "Connected",
|
||||||
"mqtt0": "Not connected",
|
"mqtt0": "Not connected",
|
||||||
"mqtt1": "Connected"
|
"mqtt1": "Connected",
|
||||||
|
"settingsTitle": "Charger settings",
|
||||||
|
"apply": "Apply",
|
||||||
|
"turnOn": "Turn on",
|
||||||
|
"turnOff": "Turn off",
|
||||||
|
"limitFloorHint": "{amps} A is the floor — below it the charger pauses rather than charging slowly.",
|
||||||
|
"boostHint": "The current session only; the charger clears it when the session ends.",
|
||||||
|
"timeoutHint": "At least {n} seconds. The charger falls back to its own strategy if nothing writes within it.",
|
||||||
|
"settingsReported": "Reported, not settable",
|
||||||
|
"settingsReportedHint": "The charger reports these; the Modbus map has no register to write them. Change them in the Anker app.",
|
||||||
|
"blockCharging": "Charging",
|
||||||
|
"blockSchedule": "Schedule",
|
||||||
|
"blockBalancing": "Load balancing",
|
||||||
|
"blockSolar": "Solar",
|
||||||
|
"blockPanel": "Panel and light",
|
||||||
|
"blockLocal": "Local network",
|
||||||
|
"windowStart": "Start",
|
||||||
|
"windowEnd": "End",
|
||||||
|
"reset": "Undo",
|
||||||
|
"cloudSettingsHint": "The charger's own settings, written over the Anker cloud. A section is one command to the charger, so its fields are applied together.",
|
||||||
|
"cloudSettingsReportedHint": "The charger reports these; no command writes them. What the two modes and the flag select is undocumented, so they are shown as the numbers they are.",
|
||||||
|
"modbusOffWarning": "With the Modbus TCP server off the charger stops answering on the local network, and the Modbus control mode has nothing left to dial.",
|
||||||
|
"chargingStatus": "Charging status"
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Charger information",
|
"title": "Charger information",
|
||||||
@@ -346,7 +369,46 @@
|
|||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"rawTitle": "As the service reports it",
|
"rawTitle": "As the service reports it",
|
||||||
"rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed."
|
"rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed.",
|
||||||
|
"nickname": "Nickname",
|
||||||
|
"productCode": "Product code",
|
||||||
|
"deviceType": "Device type",
|
||||||
|
"charging": "Charging",
|
||||||
|
"statusCode": "Status code",
|
||||||
|
"ocppLink": "OCPP connection",
|
||||||
|
"wifiOnline": "Wi-Fi connected",
|
||||||
|
"bleId": "Bluetooth id",
|
||||||
|
"blePassword": "Bluetooth pairing code",
|
||||||
|
"ownerId": "Owner id",
|
||||||
|
"fields": {
|
||||||
|
"email": "Email",
|
||||||
|
"serial": "Serial",
|
||||||
|
"memberId": "Member id",
|
||||||
|
"memberType": "Member type",
|
||||||
|
"userId": "User id",
|
||||||
|
"status": "Status",
|
||||||
|
"inviteLimit": "Invite limit",
|
||||||
|
"sessions": "Sessions",
|
||||||
|
"chargeTime": "Time charging",
|
||||||
|
"energy": "Energy charged",
|
||||||
|
"co2Saved": "CO2 saved",
|
||||||
|
"cost": "Cost",
|
||||||
|
"costSaved": "Cost saved",
|
||||||
|
"currency": "Currency",
|
||||||
|
"mileage": "Mileage",
|
||||||
|
"page": "Page",
|
||||||
|
"perPage": "Per page",
|
||||||
|
"records": "Records",
|
||||||
|
"from": "From",
|
||||||
|
"source": "Source",
|
||||||
|
"timeZone": "Time zone",
|
||||||
|
"updated": "Updated",
|
||||||
|
"added": "Added",
|
||||||
|
"address": "Address",
|
||||||
|
"name": "Name",
|
||||||
|
"cardName": "Card name",
|
||||||
|
"cardNumber": "Card number"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"count": {
|
"count": {
|
||||||
@@ -369,6 +431,60 @@
|
|||||||
},
|
},
|
||||||
"free": "{avail} of {total} free",
|
"free": "{avail} of {total} free",
|
||||||
"full": "Full"
|
"full": "Full"
|
||||||
|
},
|
||||||
|
"rfid": {
|
||||||
|
"title": "RFID cards settings",
|
||||||
|
"none": "No cards are authorised on this charger.",
|
||||||
|
"unsupported": "The service this charger came from does not report RFID cards.",
|
||||||
|
"add": "Add card",
|
||||||
|
"tap": "Tap card at the charger",
|
||||||
|
"tapping": "Hold the card against the reader… {n}s",
|
||||||
|
"tapHint": "The reader is open. Hold the card against the charger.",
|
||||||
|
"tapSave": "Tap card and add it",
|
||||||
|
"tapSaveHint": "Adds the card as soon as it is tapped, named RFID and its last four digits.",
|
||||||
|
"tapNone": "No card was tapped before the reader closed.",
|
||||||
|
"addTitle": "Add a card",
|
||||||
|
"remove": "Remove",
|
||||||
|
"removeConfirm": "Remove {name} from this charger?",
|
||||||
|
"numberPlaceholder": "Card number",
|
||||||
|
"namePlaceholder": "Name (optional)",
|
||||||
|
"notAdded": "The service took the request, but the card is not on the charger. Check the number and try again.",
|
||||||
|
"notRemoved": "The service took the request, but the card is still on the charger.",
|
||||||
|
"readCharger": "Read the charger's own list",
|
||||||
|
"chargerTitle": "On the charger itself",
|
||||||
|
"chargerNone": "The charger holds no cards.",
|
||||||
|
"chargerHint": "Asked of the charger, not of the account. It answers with numbers only — a card's name lives on the account.",
|
||||||
|
"driftTitle": "The two lists disagree",
|
||||||
|
"onlyOnCharger": "Opens the charger but is not on the account: {cards}",
|
||||||
|
"onlyOnAccount": "On the account but not on the charger, so it will not open it: {cards}",
|
||||||
|
"inferred": "Anker documents neither the add nor the remove endpoint. DriverVault infers the request from the fields the card list answers with, then reads the list back — what you see above is what the account holds."
|
||||||
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"title": "Charging tasks",
|
||||||
|
"subtitle": "One schedule for every charger you own. A task is a flow — start, limit, stop — running on the days you pick, on the chargers you pick.",
|
||||||
|
"add": "New task",
|
||||||
|
"empty": "No tasks yet. A task is a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30.",
|
||||||
|
"needCharger": "Import a charger under Home chargers first — a task needs something to act on.",
|
||||||
|
"serverHint": "Tasks run on the server, so they fire whether or not this page is open. Times are read in the time zone you wrote them in.",
|
||||||
|
"allChargers": "All chargers",
|
||||||
|
"missingChargers": "No charger on your account any more",
|
||||||
|
"everyDay": "Every day",
|
||||||
|
"runNow": "Run now",
|
||||||
|
"running": "Sending…",
|
||||||
|
"lastRun": "Last run {when}",
|
||||||
|
"noResult": "no result recorded",
|
||||||
|
"toggleHint": "Whether the clock fires this task.",
|
||||||
|
"removeConfirm": "Delete the task “{name}”?",
|
||||||
|
"taskCount": {
|
||||||
|
"one": "{n} task",
|
||||||
|
"other": "{n} tasks"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"start": "Start charging",
|
||||||
|
"stop": "Stop charging",
|
||||||
|
"limit": "Set current limit",
|
||||||
|
"boost": "Boost the session"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -415,6 +531,8 @@
|
|||||||
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
|
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
|
||||||
"controlMode": "Control mode",
|
"controlMode": "Control mode",
|
||||||
"controlModeHint": "How DriverVault controls the charger.",
|
"controlModeHint": "How DriverVault controls the charger.",
|
||||||
|
"controlModesHidden": "Hide from your users",
|
||||||
|
"controlModesHiddenHint": "Modes you tick here disappear from your users' picker and stop taking effect for them. Monitoring only is always available.",
|
||||||
"controlOff": "Off (monitoring only)",
|
"controlOff": "Off (monitoring only)",
|
||||||
"controlOwn": "Own CSMS (full control)",
|
"controlOwn": "Own CSMS (full control)",
|
||||||
"controlProxy": "Proxy CSMS (relay + control)",
|
"controlProxy": "Proxy CSMS (relay + control)",
|
||||||
@@ -528,7 +646,17 @@
|
|||||||
"fontSize": "Font size",
|
"fontSize": "Font size",
|
||||||
"fontSmall": "Small",
|
"fontSmall": "Small",
|
||||||
"fontMedium": "Medium",
|
"fontMedium": "Medium",
|
||||||
"fontLarge": "Large"
|
"fontLarge": "Large",
|
||||||
|
"timeFormat": "Time format",
|
||||||
|
"timeAuto": "Follow the region",
|
||||||
|
"time24": "24-hour",
|
||||||
|
"time12": "12-hour",
|
||||||
|
"timeExample": "Example: {example}",
|
||||||
|
"weekStart": "First day of the week",
|
||||||
|
"weekAuto": "Follow the region",
|
||||||
|
"weekMonday": "Monday",
|
||||||
|
"weekSunday": "Sunday",
|
||||||
|
"weekExample": "Example: {example}"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
@@ -1167,6 +1295,25 @@
|
|||||||
"alreadyImported": "Already imported",
|
"alreadyImported": "Already imported",
|
||||||
"name": "Charger name",
|
"name": "Charger name",
|
||||||
"submit": "Import charger"
|
"submit": "Import charger"
|
||||||
|
},
|
||||||
|
"chargingTask": {
|
||||||
|
"title": "New charging task",
|
||||||
|
"editTitle": "Edit charging task",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Night rate",
|
||||||
|
"time": "At",
|
||||||
|
"flow": "The flow",
|
||||||
|
"flowHint": "Each step fires at its own time, every day the task runs. A whole night is one task: start at 23:00, stop at 06:30.",
|
||||||
|
"addStep": "+ Add a step",
|
||||||
|
"removeStep": "Remove this step",
|
||||||
|
"amps": "Current limit",
|
||||||
|
"ampsHint": "6 A is the floor — below it the charger pauses rather than charging slowly.",
|
||||||
|
"chargers": "On these chargers",
|
||||||
|
"allChargers": "All my chargers",
|
||||||
|
"allChargersHint": "Including any charger you import later.",
|
||||||
|
"noChargers": "No chargers on your account yet.",
|
||||||
|
"days": "On these days",
|
||||||
|
"everyDay": "Every day"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"enums": {
|
"enums": {
|
||||||
|
|||||||
@@ -114,7 +114,8 @@
|
|||||||
"title": "Ładowarki w pobliżu",
|
"title": "Ładowarki w pobliżu",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"public": "Ładowarki publiczne",
|
"public": "Ładowarki publiczne",
|
||||||
"home": "Ładowarki domowe"
|
"home": "Ładowarki domowe",
|
||||||
|
"scheduler": "Harmonogram ładowarki"
|
||||||
},
|
},
|
||||||
"arrange": {
|
"arrange": {
|
||||||
"title": "Ułóż stronę ładowania",
|
"title": "Ułóż stronę ładowania",
|
||||||
@@ -283,7 +284,29 @@
|
|||||||
"ocpp1": "Łączenie",
|
"ocpp1": "Łączenie",
|
||||||
"ocpp2": "Połączona",
|
"ocpp2": "Połączona",
|
||||||
"mqtt0": "Nierozłączona",
|
"mqtt0": "Nierozłączona",
|
||||||
"mqtt1": "Połączona"
|
"mqtt1": "Połączona",
|
||||||
|
"settingsTitle": "Ustawienia ładowarki",
|
||||||
|
"apply": "Zastosuj",
|
||||||
|
"turnOn": "Włącz",
|
||||||
|
"turnOff": "Wyłącz",
|
||||||
|
"limitFloorHint": "{amps} A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
|
||||||
|
"boostHint": "Tylko bieżąca sesja; ładowarka kasuje to po jej zakończeniu.",
|
||||||
|
"timeoutHint": "Co najmniej {n} sekund. Bez zapisu w tym czasie ładowarka wraca do własnej strategii.",
|
||||||
|
"settingsReported": "Raportowane, nieustawialne",
|
||||||
|
"settingsReportedHint": "Ładowarka je raportuje; mapa Modbus nie ma rejestru do ich zapisu. Zmień je w aplikacji Anker.",
|
||||||
|
"blockCharging": "Ładowanie",
|
||||||
|
"blockSchedule": "Harmonogram",
|
||||||
|
"blockBalancing": "Balansowanie obciążenia",
|
||||||
|
"blockSolar": "Fotowoltaika",
|
||||||
|
"blockPanel": "Panel i podświetlenie",
|
||||||
|
"blockLocal": "Sieć lokalna",
|
||||||
|
"windowStart": "Początek",
|
||||||
|
"windowEnd": "Koniec",
|
||||||
|
"reset": "Cofnij",
|
||||||
|
"cloudSettingsHint": "Własne ustawienia ładowarki, zapisywane przez chmurę Anker. Jedna sekcja to jedno polecenie do ładowarki, więc jej pola są zapisywane razem.",
|
||||||
|
"cloudSettingsReportedHint": "Ładowarka je zgłasza, ale żadne polecenie ich nie zapisuje. Nie wiadomo, co wybierają te dwa tryby i flaga, więc pokazane są jako liczby, którymi są.",
|
||||||
|
"modbusOffWarning": "Przy wyłączonym serwerze Modbus TCP ładowarka przestaje odpowiadać w sieci lokalnej, a tryb sterowania Modbus nie ma już pod co zadzwonić.",
|
||||||
|
"chargingStatus": "Status ładowania"
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Informacje o ładowarce",
|
"title": "Informacje o ładowarce",
|
||||||
@@ -348,7 +371,46 @@
|
|||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"refresh": "Odśwież",
|
"refresh": "Odśwież",
|
||||||
"rawTitle": "Tak, jak podaje to usługa",
|
"rawTitle": "Tak, jak podaje to usługa",
|
||||||
"rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw."
|
"rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw.",
|
||||||
|
"nickname": "Nazwa własna",
|
||||||
|
"productCode": "Kod produktu",
|
||||||
|
"deviceType": "Typ urządzenia",
|
||||||
|
"charging": "Ładowanie",
|
||||||
|
"statusCode": "Kod statusu",
|
||||||
|
"ocppLink": "Połączenie OCPP",
|
||||||
|
"wifiOnline": "Wi-Fi połączone",
|
||||||
|
"bleId": "Identyfikator Bluetooth",
|
||||||
|
"blePassword": "Kod parowania Bluetooth",
|
||||||
|
"ownerId": "Identyfikator właściciela",
|
||||||
|
"fields": {
|
||||||
|
"email": "E-mail",
|
||||||
|
"serial": "Numer seryjny",
|
||||||
|
"memberId": "Identyfikator członka",
|
||||||
|
"memberType": "Typ członka",
|
||||||
|
"userId": "Identyfikator użytkownika",
|
||||||
|
"status": "Status",
|
||||||
|
"inviteLimit": "Limit zaproszeń",
|
||||||
|
"sessions": "Sesje",
|
||||||
|
"chargeTime": "Czas ładowania",
|
||||||
|
"energy": "Naładowana energia",
|
||||||
|
"co2Saved": "Oszczędność CO2",
|
||||||
|
"cost": "Koszt",
|
||||||
|
"costSaved": "Oszczędność kosztów",
|
||||||
|
"currency": "Waluta",
|
||||||
|
"mileage": "Przebieg",
|
||||||
|
"page": "Strona",
|
||||||
|
"perPage": "Na stronę",
|
||||||
|
"records": "Rekordy",
|
||||||
|
"from": "Od",
|
||||||
|
"source": "Źródło",
|
||||||
|
"timeZone": "Strefa czasowa",
|
||||||
|
"updated": "Zaktualizowano",
|
||||||
|
"added": "Dodano",
|
||||||
|
"address": "Adres",
|
||||||
|
"name": "Nazwa",
|
||||||
|
"cardName": "Nazwa karty",
|
||||||
|
"cardNumber": "Numer karty"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"count": {
|
"count": {
|
||||||
@@ -375,6 +437,62 @@
|
|||||||
},
|
},
|
||||||
"free": "{avail} z {total} wolnych",
|
"free": "{avail} z {total} wolnych",
|
||||||
"full": "Zajęte"
|
"full": "Zajęte"
|
||||||
|
},
|
||||||
|
"rfid": {
|
||||||
|
"title": "Ustawienia kart RFID",
|
||||||
|
"none": "Na tej ładowarce nie autoryzowano żadnej karty.",
|
||||||
|
"unsupported": "Usługa, z której pochodzi ta ładowarka, nie zgłasza kart RFID.",
|
||||||
|
"add": "Dodaj kartę",
|
||||||
|
"tap": "Przyłóż kartę do ładowarki",
|
||||||
|
"tapping": "Przytrzymaj kartę przy czytniku… {n}s",
|
||||||
|
"tapHint": "Czytnik jest otwarty. Przytrzymaj kartę przy ładowarce.",
|
||||||
|
"tapSave": "Przyłóż kartę i dodaj ją",
|
||||||
|
"tapSaveHint": "Dodaje kartę zaraz po przyłożeniu, pod nazwą RFID i cztery ostatnie znaki numeru.",
|
||||||
|
"tapNone": "Nie przyłożono karty, zanim czytnik się zamknął.",
|
||||||
|
"addTitle": "Dodaj kartę",
|
||||||
|
"remove": "Usuń",
|
||||||
|
"removeConfirm": "Usunąć {name} z tej ładowarki?",
|
||||||
|
"numberPlaceholder": "Numer karty",
|
||||||
|
"namePlaceholder": "Nazwa (opcjonalnie)",
|
||||||
|
"notAdded": "Usługa przyjęła żądanie, ale karty nie ma na ładowarce. Sprawdź numer i spróbuj ponownie.",
|
||||||
|
"notRemoved": "Usługa przyjęła żądanie, ale karta nadal jest na ładowarce.",
|
||||||
|
"readCharger": "Odczytaj własną listę ładowarki",
|
||||||
|
"chargerTitle": "Na samej ładowarce",
|
||||||
|
"chargerNone": "Ładowarka nie ma żadnych kart.",
|
||||||
|
"chargerHint": "Zapytana została ładowarka, nie konto. Odpowiada samymi numerami — nazwa karty jest po stronie konta.",
|
||||||
|
"driftTitle": "Obie listy się nie zgadzają",
|
||||||
|
"onlyOnCharger": "Otwiera ładowarkę, ale nie ma jej na koncie: {cards}",
|
||||||
|
"onlyOnAccount": "Jest na koncie, ale nie na ładowarce, więc jej nie otworzy: {cards}",
|
||||||
|
"inferred": "Anker nie dokumentuje ani dodawania, ani usuwania. DriverVault wnioskuje żądanie z pól, którymi odpowiada lista kart, a potem odczytuje listę ponownie — powyżej widzisz to, co ma konto."
|
||||||
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"title": "Zadania ładowania",
|
||||||
|
"subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Zadanie to przebieg — start, limit, stop — wykonywany w wybrane dni, na wybranych ładowarkach.",
|
||||||
|
"add": "Nowe zadanie",
|
||||||
|
"empty": "Brak zadań. Zadanie to cały przebieg: start o 23:00, ograniczenie do 10 A o 01:00, stop o 06:30.",
|
||||||
|
"needCharger": "Najpierw zaimportuj ładowarkę w zakładce Ładowarki domowe — zadanie musi mieć na czym działać.",
|
||||||
|
"serverHint": "Zadania działają na serwerze, więc uruchamiają się niezależnie od tego, czy ta strona jest otwarta. Godziny są odczytywane w strefie czasowej, w której je zapisano.",
|
||||||
|
"allChargers": "Wszystkie ładowarki",
|
||||||
|
"missingChargers": "Nie ma już takiej ładowarki na koncie",
|
||||||
|
"everyDay": "Codziennie",
|
||||||
|
"runNow": "Uruchom teraz",
|
||||||
|
"running": "Wysyłanie…",
|
||||||
|
"lastRun": "Ostatnio {when}",
|
||||||
|
"noResult": "brak zapisanego wyniku",
|
||||||
|
"toggleHint": "Czy zegar uruchamia to zadanie.",
|
||||||
|
"removeConfirm": "Usunąć zadanie „{name}”?",
|
||||||
|
"taskCount": {
|
||||||
|
"one": "{n} zadanie",
|
||||||
|
"few": "{n} zadania",
|
||||||
|
"many": "{n} zadań",
|
||||||
|
"other": "{n} zadania"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"start": "Rozpocznij ładowanie",
|
||||||
|
"stop": "Zatrzymaj ładowanie",
|
||||||
|
"limit": "Ustaw limit prądu",
|
||||||
|
"boost": "Przyspiesz sesję"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -421,7 +539,17 @@
|
|||||||
"fontSize": "Rozmiar czcionki",
|
"fontSize": "Rozmiar czcionki",
|
||||||
"fontSmall": "Mała",
|
"fontSmall": "Mała",
|
||||||
"fontMedium": "Średnia",
|
"fontMedium": "Średnia",
|
||||||
"fontLarge": "Duża"
|
"fontLarge": "Duża",
|
||||||
|
"timeFormat": "Format godziny",
|
||||||
|
"timeAuto": "Jak w regionie",
|
||||||
|
"time24": "24-godzinny",
|
||||||
|
"time12": "12-godzinny",
|
||||||
|
"timeExample": "Przykład: {example}",
|
||||||
|
"weekStart": "Pierwszy dzień tygodnia",
|
||||||
|
"weekAuto": "Zgodnie z regionem",
|
||||||
|
"weekMonday": "Poniedziałek",
|
||||||
|
"weekSunday": "Niedziela",
|
||||||
|
"weekExample": "Przykład: {example}"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
@@ -541,6 +669,8 @@
|
|||||||
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
|
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
|
||||||
"controlMode": "Tryb sterowania",
|
"controlMode": "Tryb sterowania",
|
||||||
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką.",
|
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką.",
|
||||||
|
"controlModesHidden": "Ukryj przed użytkownikami",
|
||||||
|
"controlModesHiddenHint": "Zaznaczone tryby znikają z listy Twoich użytkowników i przestają dla nich działać. Tylko monitorowanie jest zawsze dostępne.",
|
||||||
"controlOff": "Wyłączone (tylko monitorowanie)",
|
"controlOff": "Wyłączone (tylko monitorowanie)",
|
||||||
"controlOwn": "Własny CSMS (pełne sterowanie)",
|
"controlOwn": "Własny CSMS (pełne sterowanie)",
|
||||||
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
|
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
|
||||||
@@ -1185,6 +1315,25 @@
|
|||||||
"alreadyImported": "Już zaimportowana",
|
"alreadyImported": "Już zaimportowana",
|
||||||
"name": "Nazwa ładowarki",
|
"name": "Nazwa ładowarki",
|
||||||
"submit": "Importuj ładowarkę"
|
"submit": "Importuj ładowarkę"
|
||||||
|
},
|
||||||
|
"chargingTask": {
|
||||||
|
"title": "Nowe zadanie ładowania",
|
||||||
|
"editTitle": "Edytuj zadanie ładowania",
|
||||||
|
"name": "Nazwa",
|
||||||
|
"namePlaceholder": "Taryfa nocna",
|
||||||
|
"time": "O godzinie",
|
||||||
|
"flow": "Przebieg",
|
||||||
|
"flowHint": "Każdy krok uruchamia się o własnej godzinie, w każdy dzień działania zadania. Cała noc to jedno zadanie: start o 23:00, stop o 06:30.",
|
||||||
|
"addStep": "+ Dodaj krok",
|
||||||
|
"removeStep": "Usuń ten krok",
|
||||||
|
"amps": "Limit prądu",
|
||||||
|
"ampsHint": "6 A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
|
||||||
|
"chargers": "Na tych ładowarkach",
|
||||||
|
"allChargers": "Wszystkie moje ładowarki",
|
||||||
|
"allChargersHint": "Łącznie z ładowarkami zaimportowanymi później.",
|
||||||
|
"noChargers": "Na koncie nie ma jeszcze ładowarek.",
|
||||||
|
"days": "W te dni",
|
||||||
|
"everyDay": "Codziennie"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"enums": {
|
"enums": {
|
||||||
|
|||||||
@@ -652,6 +652,50 @@ class ApiClient {
|
|||||||
return ChargerDetails.fromJson(Map<String, dynamic>.from(data));
|
return ChargerDetails.fromJson(Map<String, dynamic>.from(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The RFID cards on one charger — the only calls in this client that change
|
||||||
|
// anything on the Anker account. Anker documents neither endpoint, so the
|
||||||
|
// server infers the request and then reads the list back: both of these answer
|
||||||
|
// with {present, cards}, and it is the list that says what happened, not the
|
||||||
|
// status code.
|
||||||
|
Future<RfidCardWrite> saveAnkerRfidCard(String sn, String cardNumber, String cardName) async {
|
||||||
|
final data = await _send(
|
||||||
|
"POST",
|
||||||
|
"/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards",
|
||||||
|
body: {"cardNumber": cardNumber, "cardName": cardName},
|
||||||
|
);
|
||||||
|
if (data is! Map) return const RfidCardWrite();
|
||||||
|
return RfidCardWrite.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the charger's own card reader and waits for a tap — the request is in
|
||||||
|
/// flight for the whole twenty-second window, and answers whether or not a
|
||||||
|
/// card arrived.
|
||||||
|
Future<RfidScan> scanAnkerRfidCard(String sn) async {
|
||||||
|
final data = await _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/scan");
|
||||||
|
if (data is! Map) return const RfidScan();
|
||||||
|
return RfidScan.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<RfidCardWrite> deleteAnkerRfidCard(String sn, String cardNumber) async {
|
||||||
|
final data = await _send(
|
||||||
|
"DELETE",
|
||||||
|
"/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/${Uri.encodeComponent(cardNumber)}",
|
||||||
|
);
|
||||||
|
if (data is! Map) return const RfidCardWrite();
|
||||||
|
return RfidCardWrite.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list the charger itself holds, asked of the device rather than of the
|
||||||
|
/// account. Both are written by every add and remove, and they can still come
|
||||||
|
/// apart; this is the only call that says so. Answers with bare numbers,
|
||||||
|
/// because the device has no field for a card's name.
|
||||||
|
Future<List<String>> getAnkerChargerCards(String sn) async {
|
||||||
|
final data =
|
||||||
|
await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/charger");
|
||||||
|
final raw = data is Map ? data["cards"] : null;
|
||||||
|
return raw is List ? raw.map(_asString).where((c) => c.isNotEmpty).toList() : const [];
|
||||||
|
}
|
||||||
|
|
||||||
// Greencell (HabuDen EV charger). Same cascade, but what resolves is an MQTT
|
// Greencell (HabuDen EV charger). Same cascade, but what resolves is an MQTT
|
||||||
// broker rather than a cloud account — the charger publishes to a broker the
|
// broker rather than a cloud account — the charger publishes to a broker the
|
||||||
// owner runs and the server joins it. testGreencell connects to that broker and
|
// owner runs and the server joins it. testGreencell connects to that broker and
|
||||||
@@ -772,5 +816,44 @@ class ApiClient {
|
|||||||
Future<void> deleteHomeCharger(String id) =>
|
Future<void> deleteHomeCharger(String id) =>
|
||||||
_send("DELETE", "/home-chargers/${Uri.encodeComponent(id)}");
|
_send("DELETE", "/home-chargers/${Uri.encodeComponent(id)}");
|
||||||
|
|
||||||
|
// --- the charging scheduler ---
|
||||||
|
//
|
||||||
|
// One list of charging tasks per user, covering every charger they own. The
|
||||||
|
// server holds the clock — a schedule that only fires while the app is open
|
||||||
|
// would be a reminder, not a schedule — so the app only writes tasks and reads
|
||||||
|
// back how each one last went.
|
||||||
|
|
||||||
|
Future<List<ChargingTask>> listChargingTasks() async {
|
||||||
|
final data = await _send("GET", "/charging-tasks");
|
||||||
|
final items = (data is Map ? data["tasks"] : null) ?? [];
|
||||||
|
return (items as List)
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => ChargingTask.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ChargingTask> createChargingTask(Map<String, dynamic> body) async {
|
||||||
|
final data = await _send("POST", "/charging-tasks", body: body);
|
||||||
|
return ChargingTask.fromJson(Map<String, dynamic>.from((data is Map ? data["task"] : null) ?? {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ChargingTask> updateChargingTask(String id, Map<String, dynamic> body) async {
|
||||||
|
final data =
|
||||||
|
await _send("PATCH", "/charging-tasks/${Uri.encodeComponent(id)}", body: body);
|
||||||
|
return ChargingTask.fromJson(Map<String, dynamic>.from((data is Map ? data["task"] : null) ?? {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteChargingTask(String id) =>
|
||||||
|
_send("DELETE", "/charging-tasks/${Uri.encodeComponent(id)}");
|
||||||
|
|
||||||
|
/// One step of a flow, fired now: running a start and the stop that closes it
|
||||||
|
/// back to back would leave the charger where it began and prove nothing.
|
||||||
|
/// Answers with the same summary the clock's own firing would record.
|
||||||
|
Future<String> runChargingStep(String id, int step) async {
|
||||||
|
final data = await _send(
|
||||||
|
"POST", "/charging-tasks/${Uri.encodeComponent(id)}/steps/$step/run");
|
||||||
|
return data is Map ? _asString(data["summary"]) : "";
|
||||||
|
}
|
||||||
|
|
||||||
static String _asString(dynamic v) => v == null ? "" : v.toString();
|
static String _asString(dynamic v) => v == null ? "" : v.toString();
|
||||||
}
|
}
|
||||||
@@ -11,12 +11,25 @@ class AppSettings extends ChangeNotifier {
|
|||||||
static const _kTheme = "cc_theme";
|
static const _kTheme = "cc_theme";
|
||||||
static const _kLocale = "cc_locale";
|
static const _kLocale = "cc_locale";
|
||||||
static const _kDateFormat = "cc_dateFormat";
|
static const _kDateFormat = "cc_dateFormat";
|
||||||
|
static const _kTimeFormat = "cc_timeFormat";
|
||||||
|
static const _kWeekStart = "cc_weekStart";
|
||||||
static const _kCurrency = "cc_currency";
|
static const _kCurrency = "cc_currency";
|
||||||
static const _kFontSize = "cc_fontSize";
|
static const _kFontSize = "cc_fontSize";
|
||||||
|
|
||||||
String theme = "system"; // light | dark | system
|
String theme = "system"; // light | dark | system
|
||||||
String locale = "en-US"; // BCP-47 language-REGION
|
String locale = "en-US"; // BCP-47 language-REGION
|
||||||
String dateFormat = "YMD"; // YMD | DMY_NUM | DMY | MDY
|
String dateFormat = "YMD"; // YMD | DMY_NUM | DMY | MDY
|
||||||
|
|
||||||
|
/// Which clock times are written on. "auto" is the chosen region's own
|
||||||
|
/// convention, which is what every time in the app read before there was a
|
||||||
|
/// setting; the other two are for the people whose region and habit disagree.
|
||||||
|
String timeFormat = "auto"; // auto | 24 | 12
|
||||||
|
|
||||||
|
/// The day a week is drawn as starting on, wherever weekdays are laid out in a
|
||||||
|
/// row — the charging scheduler's day picker today. See format.dart, which
|
||||||
|
/// owns the rule so every such row reads the same.
|
||||||
|
String weekStart = "auto"; // auto | monday | sunday
|
||||||
|
|
||||||
String currency = "USD"; // ISO 4217 code
|
String currency = "USD"; // ISO 4217 code
|
||||||
String fontSize = "medium"; // small | medium | large
|
String fontSize = "medium"; // small | medium | large
|
||||||
|
|
||||||
@@ -30,6 +43,8 @@ class AppSettings extends ChangeNotifier {
|
|||||||
theme = prefs.getString(_kTheme) ?? theme;
|
theme = prefs.getString(_kTheme) ?? theme;
|
||||||
locale = prefs.getString(_kLocale) ?? locale;
|
locale = prefs.getString(_kLocale) ?? locale;
|
||||||
dateFormat = prefs.getString(_kDateFormat) ?? dateFormat;
|
dateFormat = prefs.getString(_kDateFormat) ?? dateFormat;
|
||||||
|
timeFormat = prefs.getString(_kTimeFormat) ?? timeFormat;
|
||||||
|
weekStart = prefs.getString(_kWeekStart) ?? weekStart;
|
||||||
currency = prefs.getString(_kCurrency) ?? currency;
|
currency = prefs.getString(_kCurrency) ?? currency;
|
||||||
fontSize = prefs.getString(_kFontSize) ?? fontSize;
|
fontSize = prefs.getString(_kFontSize) ?? fontSize;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -40,6 +55,8 @@ class AppSettings extends ChangeNotifier {
|
|||||||
await prefs.setString(_kTheme, theme);
|
await prefs.setString(_kTheme, theme);
|
||||||
await prefs.setString(_kLocale, locale);
|
await prefs.setString(_kLocale, locale);
|
||||||
await prefs.setString(_kDateFormat, dateFormat);
|
await prefs.setString(_kDateFormat, dateFormat);
|
||||||
|
await prefs.setString(_kTimeFormat, timeFormat);
|
||||||
|
await prefs.setString(_kWeekStart, weekStart);
|
||||||
await prefs.setString(_kCurrency, currency);
|
await prefs.setString(_kCurrency, currency);
|
||||||
await prefs.setString(_kFontSize, fontSize);
|
await prefs.setString(_kFontSize, fontSize);
|
||||||
}
|
}
|
||||||
@@ -49,6 +66,8 @@ class AppSettings extends ChangeNotifier {
|
|||||||
theme = p.theme;
|
theme = p.theme;
|
||||||
locale = p.locale;
|
locale = p.locale;
|
||||||
dateFormat = p.dateFormat;
|
dateFormat = p.dateFormat;
|
||||||
|
timeFormat = p.timeFormat;
|
||||||
|
weekStart = p.weekStart;
|
||||||
currency = p.currency;
|
currency = p.currency;
|
||||||
fontSize = p.fontSize;
|
fontSize = p.fontSize;
|
||||||
_persist();
|
_persist();
|
||||||
@@ -61,12 +80,16 @@ class AppSettings extends ChangeNotifier {
|
|||||||
String? theme,
|
String? theme,
|
||||||
String? locale,
|
String? locale,
|
||||||
String? dateFormat,
|
String? dateFormat,
|
||||||
|
String? timeFormat,
|
||||||
|
String? weekStart,
|
||||||
String? currency,
|
String? currency,
|
||||||
String? fontSize,
|
String? fontSize,
|
||||||
}) {
|
}) {
|
||||||
if (theme != null) this.theme = theme;
|
if (theme != null) this.theme = theme;
|
||||||
if (locale != null) this.locale = locale;
|
if (locale != null) this.locale = locale;
|
||||||
if (dateFormat != null) this.dateFormat = dateFormat;
|
if (dateFormat != null) this.dateFormat = dateFormat;
|
||||||
|
if (timeFormat != null) this.timeFormat = timeFormat;
|
||||||
|
if (weekStart != null) this.weekStart = weekStart;
|
||||||
if (currency != null) this.currency = currency;
|
if (currency != null) this.currency = currency;
|
||||||
if (fontSize != null) this.fontSize = fontSize;
|
if (fontSize != null) this.fontSize = fontSize;
|
||||||
_persist();
|
_persist();
|
||||||
|
|||||||
+143
-1
@@ -71,7 +71,149 @@ String formatPartialDate(String iso) {
|
|||||||
/// disagree on screen.
|
/// disagree on screen.
|
||||||
String formatDateTime(DateTime? d) {
|
String formatDateTime(DateTime? d) {
|
||||||
if (d == null) return "—";
|
if (d == null) return "—";
|
||||||
return "${formatDate(d)} ${DateFormat.Hm(_locale).format(d)}";
|
return "${formatDate(d)} ${formatTime(d)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clock alone. "auto" leaves the reading to the region, which is what every
|
||||||
|
/// time in the app said before there was a setting; the other two are for the
|
||||||
|
/// people whose region and habit disagree — plenty of Poles read 12-hour clocks
|
||||||
|
/// and plenty of Americans read 24-hour ones, and the region picker also decides
|
||||||
|
/// how money and numbers are grouped, so it is the wrong lever to reach for.
|
||||||
|
///
|
||||||
|
/// The setting decides *which* clock; this file decides how it is punctuated.
|
||||||
|
/// A region is worth asking whether a reader expects 13:45 or 01:45 pm — that is
|
||||||
|
/// a real difference in how people tell the time. It is not worth asking whether
|
||||||
|
/// the two numbers are joined by a colon or a dot: Danish writes 13.45, and one
|
||||||
|
/// screen of DriverVault writing 13.45 while the next writes 13:45 is not local
|
||||||
|
/// colour, it is an inconsistency. So every time this app prints comes out of
|
||||||
|
/// the two lines below, the same as the web app's format.js.
|
||||||
|
///
|
||||||
|
/// The cost is that the am/pm marker reads in English everywhere. It is the same
|
||||||
|
/// trade the setting itself makes: a 12-hour clock is not a convention most of
|
||||||
|
/// these regions use, so choosing one — or living in a region that does — is
|
||||||
|
/// choosing the clock that comes with it.
|
||||||
|
String formatTime(DateTime? d) {
|
||||||
|
if (d == null) return "—";
|
||||||
|
final mm = d.minute.toString().padLeft(2, "0");
|
||||||
|
if (!clockIsTwelveHour()) return "${d.hour.toString().padLeft(2, "0")}:$mm";
|
||||||
|
// 12 for both noon and midnight, and midnight is the am one.
|
||||||
|
final h = d.hour % 12 == 0 ? 12 : d.hour % 12;
|
||||||
|
return "${h.toString().padLeft(2, "0")}:$mm ${d.hour < 12 ? "am" : "pm"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wall-clock "HH:MM" — a schedule is a time of day, not a moment, so there is
|
||||||
|
/// no date to hand [formatTime] — read on the clock the user chose. Anything
|
||||||
|
/// that is not a time of day comes back as it arrived.
|
||||||
|
String formatClock(String hhmm) {
|
||||||
|
final m = RegExp(r"^(\d{1,2}):(\d{2})$").firstMatch(hhmm.trim());
|
||||||
|
if (m == null) return hhmm;
|
||||||
|
final h = int.parse(m.group(1)!);
|
||||||
|
if (!clockIsTwelveHour()) return "${h.toString().padLeft(2, "0")}:${m.group(2)}";
|
||||||
|
final twelve = h % 12 == 0 ? 12 : h % 12;
|
||||||
|
return "${twelve.toString().padLeft(2, "0")}:${m.group(2)} ${h < 12 ? "am" : "pm"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether times are written on a 12-hour clock right now: what the setting says
|
||||||
|
/// outright, or what the region says when it is left on auto.
|
||||||
|
///
|
||||||
|
/// Not only [formatTime]'s business. A control that lets somebody *enter* a time
|
||||||
|
/// has to offer the same clock, and a box that reads 13:45 beside a picker that
|
||||||
|
/// says 01:45 PM is the disagreement this setting exists to end — see
|
||||||
|
/// widgets/time_field.dart.
|
||||||
|
bool clockIsTwelveHour() {
|
||||||
|
switch (appSettings.timeFormat) {
|
||||||
|
case "12":
|
||||||
|
return true;
|
||||||
|
case "24":
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return _regionReadsTwelveHour();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the chosen region tells the time on a 12-hour clock — the one
|
||||||
|
/// question "auto" asks it. Cached because this is asked once per timestamp on a
|
||||||
|
/// page that can hold a great many, and the answer only changes with the region.
|
||||||
|
final Map<String, bool> _twelveHourRegions = {};
|
||||||
|
|
||||||
|
bool _regionReadsTwelveHour() {
|
||||||
|
return _twelveHourRegions.putIfAbsent(_locale, () {
|
||||||
|
try {
|
||||||
|
// DateFormat.j() is the locale's own preferred hour field: "h" where it is
|
||||||
|
// read on a 12-hour clock, "H" where it is not.
|
||||||
|
return DateFormat.j(_locale).pattern?.contains("h") ?? false;
|
||||||
|
} catch (_) {
|
||||||
|
// An unusable locale is not a reason to print nothing; 24-hour is the
|
||||||
|
// safer default, being the one that cannot be read as the wrong half of
|
||||||
|
// the day.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Weekdays ---------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// A week does not start on the same day everywhere: Monday across most of
|
||||||
|
// Europe, Sunday in the US and a good deal of Asia. A row of weekday buttons
|
||||||
|
// that always begins on Sunday reads wrong to half the people looking at it,
|
||||||
|
// and reads wrong in a way that is easy to mistap — Settings › Appearance ›
|
||||||
|
// First day of the week is the answer, with "auto" following the chosen region
|
||||||
|
// the way the clock setting does.
|
||||||
|
//
|
||||||
|
// Everything that lays weekdays out in a row goes through these, so there is one
|
||||||
|
// answer to "which day comes first" rather than one per screen. Days are
|
||||||
|
// numbered the way the scheduler's stored tasks number them: 0 = Sunday …
|
||||||
|
// 6 = Saturday.
|
||||||
|
|
||||||
|
/// Whether weeks are drawn as starting on Monday right now: what the setting
|
||||||
|
/// says outright, or what the region says when it is left on auto.
|
||||||
|
bool weekStartsOnMonday() {
|
||||||
|
switch (appSettings.weekStart) {
|
||||||
|
case "monday":
|
||||||
|
return true;
|
||||||
|
case "sunday":
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return _regionStartsOnMonday();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, bool> _mondayRegions = {};
|
||||||
|
|
||||||
|
bool _regionStartsOnMonday() {
|
||||||
|
return _mondayRegions.putIfAbsent(_locale, () {
|
||||||
|
try {
|
||||||
|
// intl carries the region's own answer in its date symbols, numbered
|
||||||
|
// 0 = Monday … 6 = Sunday (the Closure convention its data came from).
|
||||||
|
return DateFormat.yMd(_locale).dateSymbols.FIRSTDAYOFWEEK == 0;
|
||||||
|
} catch (_) {
|
||||||
|
// Monday is the safer default: it is ISO 8601's, and the convention in
|
||||||
|
// every region this app's own currency list covers bar one.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The seven days in the order they should be drawn, as day numbers.
|
||||||
|
List<int> weekdaysInOrder() =>
|
||||||
|
weekStartsOnMonday() ? const [1, 2, 3, 4, 5, 6, 0] : const [0, 1, 2, 3, 4, 5, 6];
|
||||||
|
|
||||||
|
/// One day's short name in the user's own language, so a row reads Pn Wt Śr in
|
||||||
|
/// Polish without a table here. 2024-01-07 was a Sunday, which is where day 0
|
||||||
|
/// sits, so the offset lands each number on its own day.
|
||||||
|
String weekdayShortName(int day) {
|
||||||
|
try {
|
||||||
|
return DateFormat.E(_locale).format(DateTime.utc(2024, 1, 7 + day));
|
||||||
|
} catch (_) {
|
||||||
|
return "$day";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A set of days, listed in the order this account reads a week in — so the same
|
||||||
|
/// three days always come out in the same order wherever they are shown.
|
||||||
|
List<int> sortWeekdays(Iterable<int> days) {
|
||||||
|
final order = weekdaysInOrder();
|
||||||
|
return days.toList()..sort((a, b) => order.indexOf(a).compareTo(order.indexOf(b)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0 km is a reading — a car collected new — not a blank. See format.js.
|
// 0 km is a reading — a car collected new — not a blank. See format.js.
|
||||||
|
|||||||
+207
-1
@@ -890,6 +890,12 @@ class UserProfile {
|
|||||||
final String theme; // light | dark | system
|
final String theme; // light | dark | system
|
||||||
final String locale; // BCP-47 language-REGION, e.g. "en-US"
|
final String locale; // BCP-47 language-REGION, e.g. "en-US"
|
||||||
final String dateFormat; // YMD | DMY_NUM | DMY | MDY
|
final String dateFormat; // YMD | DMY_NUM | DMY | MDY
|
||||||
|
final String timeFormat; // auto (the region's own) | 24 | 12
|
||||||
|
|
||||||
|
/// The day a week is drawn as starting on, wherever weekdays are laid out in
|
||||||
|
/// a row — the charging scheduler's day picker today.
|
||||||
|
final String weekStart; // auto (the region's own) | monday | sunday
|
||||||
|
|
||||||
final String currency; // ISO 4217 code, e.g. "EUR"
|
final String currency; // ISO 4217 code, e.g. "EUR"
|
||||||
final String fontSize; // small | medium | large
|
final String fontSize; // small | medium | large
|
||||||
final String role; // user | admin
|
final String role; // user | admin
|
||||||
@@ -914,6 +920,8 @@ class UserProfile {
|
|||||||
required this.theme,
|
required this.theme,
|
||||||
required this.locale,
|
required this.locale,
|
||||||
required this.dateFormat,
|
required this.dateFormat,
|
||||||
|
this.timeFormat = "auto",
|
||||||
|
this.weekStart = "auto",
|
||||||
this.currency = "USD",
|
this.currency = "USD",
|
||||||
required this.fontSize,
|
required this.fontSize,
|
||||||
required this.role,
|
required this.role,
|
||||||
@@ -934,6 +942,8 @@ class UserProfile {
|
|||||||
theme: j["theme"] == null ? "system" : _asStr(j["theme"]),
|
theme: j["theme"] == null ? "system" : _asStr(j["theme"]),
|
||||||
locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]),
|
locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]),
|
||||||
dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]),
|
dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]),
|
||||||
|
timeFormat: j["timeFormat"] == null ? "auto" : _asStr(j["timeFormat"]),
|
||||||
|
weekStart: j["weekStart"] == null ? "auto" : _asStr(j["weekStart"]),
|
||||||
currency: j["currency"] == null ? "USD" : _asStr(j["currency"]),
|
currency: j["currency"] == null ? "USD" : _asStr(j["currency"]),
|
||||||
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
|
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
|
||||||
role: j["role"] == null ? "user" : _asStr(j["role"]),
|
role: j["role"] == null ? "user" : _asStr(j["role"]),
|
||||||
@@ -988,7 +998,21 @@ class IntegrationScope {
|
|||||||
final String editableLayer; // "user" | "org"
|
final String editableLayer; // "user" | "org"
|
||||||
final Map<String, IntegrationField> fields;
|
final Map<String, IntegrationField> fields;
|
||||||
|
|
||||||
const IntegrationScope({this.editableLayer = "user", this.fields = const {}});
|
/// Anker only. The control modes this scope may still choose — the server has
|
||||||
|
/// already dropped whatever the layers above it hid. Empty for an integration
|
||||||
|
/// that has no such list.
|
||||||
|
final List<String> controlModes;
|
||||||
|
|
||||||
|
/// Anker, org scope only. The modes this organization hides from its own
|
||||||
|
/// users, which its admin edits here.
|
||||||
|
final List<String> controlModesDisabled;
|
||||||
|
|
||||||
|
const IntegrationScope({
|
||||||
|
this.editableLayer = "user",
|
||||||
|
this.fields = const {},
|
||||||
|
this.controlModes = const [],
|
||||||
|
this.controlModesDisabled = const [],
|
||||||
|
});
|
||||||
|
|
||||||
factory IntegrationScope.fromJson(Map<String, dynamic> j) {
|
factory IntegrationScope.fromJson(Map<String, dynamic> j) {
|
||||||
final raw = j["fields"];
|
final raw = j["fields"];
|
||||||
@@ -1001,6 +1025,8 @@ class IntegrationScope {
|
|||||||
return IntegrationScope(
|
return IntegrationScope(
|
||||||
editableLayer: _asStr(j["editableLayer"]).isEmpty ? "user" : _asStr(j["editableLayer"]),
|
editableLayer: _asStr(j["editableLayer"]).isEmpty ? "user" : _asStr(j["editableLayer"]),
|
||||||
fields: fields,
|
fields: fields,
|
||||||
|
controlModes: _asStrList(j["controlModes"]),
|
||||||
|
controlModesDisabled: _asStrList(j["controlModesDisabled"]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,6 +1261,10 @@ class AnkerCharger {
|
|||||||
final String statusDesc; // charging | standby | … as the cloud names it
|
final String statusDesc; // charging | standby | … as the cloud names it
|
||||||
final bool? online; // null when no view reported a connection state
|
final bool? online; // null when no view reported a connection state
|
||||||
|
|
||||||
|
/// The product shot the service holds for this model, when it sent one. A URL
|
||||||
|
/// rather than an image: it is fetched only where it is drawn.
|
||||||
|
final String imageUrl;
|
||||||
|
|
||||||
const AnkerCharger({
|
const AnkerCharger({
|
||||||
required this.sn,
|
required this.sn,
|
||||||
this.name = "",
|
this.name = "",
|
||||||
@@ -1243,6 +1273,7 @@ class AnkerCharger {
|
|||||||
this.siteName = "",
|
this.siteName = "",
|
||||||
this.statusDesc = "",
|
this.statusDesc = "",
|
||||||
this.online,
|
this.online,
|
||||||
|
this.imageUrl = "",
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AnkerCharger.fromJson(Map<String, dynamic> j) => AnkerCharger(
|
factory AnkerCharger.fromJson(Map<String, dynamic> j) => AnkerCharger(
|
||||||
@@ -1253,6 +1284,7 @@ class AnkerCharger {
|
|||||||
siteName: _asStr(j["siteName"]),
|
siteName: _asStr(j["siteName"]),
|
||||||
statusDesc: _asStr(j["statusDesc"]),
|
statusDesc: _asStr(j["statusDesc"]),
|
||||||
online: j["online"] is bool ? j["online"] as bool : null,
|
online: j["online"] is bool ? j["online"] as bool : null,
|
||||||
|
imageUrl: _asStr(j["imageUrl"]),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// What to call the charger in a list: its name when it has one, its serial
|
/// What to call the charger in a list: its name when it has one, its serial
|
||||||
@@ -1284,6 +1316,180 @@ class AnkerChargerList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- RFID cards (who may start a charge without a phone) --------------------
|
||||||
|
|
||||||
|
/// One card authorised on a charger, as the account holds it. The cloud answers
|
||||||
|
/// with its own field names — alias_name, card_number, create_time — and this is
|
||||||
|
/// the same three read out: a number to delete by, the name it was given, and
|
||||||
|
/// when it was added.
|
||||||
|
class RfidCard {
|
||||||
|
final String number;
|
||||||
|
final String name;
|
||||||
|
final String added; // as the cloud sent it: unix seconds, or ""
|
||||||
|
|
||||||
|
const RfidCard({required this.number, this.name = "", this.added = ""});
|
||||||
|
|
||||||
|
factory RfidCard.fromJson(Map<String, dynamic> j) {
|
||||||
|
final number = _asStr(j["card_number"]).trim();
|
||||||
|
final name = _asStr(j["alias_name"]).trim();
|
||||||
|
return RfidCard(
|
||||||
|
number: number,
|
||||||
|
// A card with no name of its own is still a card somebody holds, and its
|
||||||
|
// number is the only honest thing to call it.
|
||||||
|
name: name.isEmpty ? number : name,
|
||||||
|
added: _asStr(j["create_time"]).trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a card write answers with: whether the account holds that card now, and
|
||||||
|
/// the whole list as it stands after the write. Anker documents neither endpoint,
|
||||||
|
/// so a 200 proves nothing on its own — it is the list that says what happened.
|
||||||
|
class RfidCardWrite {
|
||||||
|
final bool present;
|
||||||
|
final List<RfidCard> cards;
|
||||||
|
final String detail;
|
||||||
|
|
||||||
|
const RfidCardWrite({this.present = false, this.cards = const [], this.detail = ""});
|
||||||
|
|
||||||
|
factory RfidCardWrite.fromJson(Map<String, dynamic> j) => RfidCardWrite(
|
||||||
|
present: _asBool(j["present"]),
|
||||||
|
cards: j["cards"] is List
|
||||||
|
? (j["cards"] as List)
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((c) => RfidCard.fromJson(Map<String, dynamic>.from(c)))
|
||||||
|
.where((c) => c.number.isNotEmpty)
|
||||||
|
.toList()
|
||||||
|
: const [],
|
||||||
|
detail: _asStr(j["detail"]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a scan answers with: the card that was held against the reader, or the
|
||||||
|
/// plain fact that nothing was. A window that closed empty is an answer, not a
|
||||||
|
/// timeout, which is why [tapped] is separate from [card].
|
||||||
|
class RfidScan {
|
||||||
|
final bool tapped;
|
||||||
|
final String card;
|
||||||
|
|
||||||
|
const RfidScan({this.tapped = false, this.card = ""});
|
||||||
|
|
||||||
|
factory RfidScan.fromJson(Map<String, dynamic> j) =>
|
||||||
|
RfidScan(tapped: _asBool(j["tapped"]), card: _asStr(j["card"]).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the charging scheduler -------------------------------------------------
|
||||||
|
//
|
||||||
|
// The charger's own cloud schedule can say one thing — "charge between these
|
||||||
|
// hours" — and it says it inside one charger. This is a list, and each entry is
|
||||||
|
// a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30, on these
|
||||||
|
// chargers, on these days. One named thing, switched on and off as one.
|
||||||
|
|
||||||
|
/// One command in a task's flow: what to do, and at what time of day.
|
||||||
|
class ChargingStep {
|
||||||
|
/// start, stop, limit (to [amps]) or boost.
|
||||||
|
final String action;
|
||||||
|
final double amps;
|
||||||
|
|
||||||
|
/// A 24-hour "HH:MM", read in the task's zone.
|
||||||
|
final String time;
|
||||||
|
|
||||||
|
const ChargingStep({required this.action, required this.time, this.amps = 0});
|
||||||
|
|
||||||
|
factory ChargingStep.fromJson(Map<String, dynamic> j) => ChargingStep(
|
||||||
|
action: _asStr(j["action"]),
|
||||||
|
time: _asStr(j["time"]),
|
||||||
|
amps: _asDouble(j["amps"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {"action": action, "time": time, "amps": amps};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry in the home-charger scheduler. It belongs to the person, like the
|
||||||
|
/// chargers it acts on — one list covering every charger they own, rather than a
|
||||||
|
/// separate schedule inside each one.
|
||||||
|
class ChargingTask {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// The home-charger records this task acts on. Empty means every charger the
|
||||||
|
/// owner has, including ones imported after the task was written — "all of
|
||||||
|
/// them" is a standing wish, not the list that happened to exist that day.
|
||||||
|
final List<String> chargers;
|
||||||
|
|
||||||
|
/// The flow, in the order it runs.
|
||||||
|
final List<ChargingStep> steps;
|
||||||
|
|
||||||
|
/// The IANA zone the steps' times are read in. The server's own clock is not
|
||||||
|
/// the one the user set 23:00 by.
|
||||||
|
final String zone;
|
||||||
|
|
||||||
|
/// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day.
|
||||||
|
final List<int> days;
|
||||||
|
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// What happened the last time a step of it fired, so a task that has been
|
||||||
|
/// failing quietly for a week says so in the list rather than in a log nobody
|
||||||
|
/// reads.
|
||||||
|
final DateTime? lastRun;
|
||||||
|
final String lastResult;
|
||||||
|
|
||||||
|
const ChargingTask({
|
||||||
|
required this.id,
|
||||||
|
this.name = "",
|
||||||
|
this.chargers = const [],
|
||||||
|
this.steps = const [],
|
||||||
|
this.zone = "",
|
||||||
|
this.days = const [],
|
||||||
|
this.enabled = true,
|
||||||
|
this.lastRun,
|
||||||
|
this.lastResult = "",
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ChargingTask.fromJson(Map<String, dynamic> j) => ChargingTask(
|
||||||
|
id: _asStr(j["id"]),
|
||||||
|
name: _asStr(j["name"]),
|
||||||
|
chargers: _asStrList(j["chargers"]),
|
||||||
|
steps: j["steps"] is List
|
||||||
|
? (j["steps"] as List)
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => ChargingStep.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList()
|
||||||
|
: const [],
|
||||||
|
zone: _asStr(j["zone"]),
|
||||||
|
days: j["days"] is List ? (j["days"] as List).map(_asInt).toList() : const [],
|
||||||
|
enabled: _asBool(j["enabled"]),
|
||||||
|
lastRun: j["lastRun"] == null ? null : DateTime.tryParse(_asStr(j["lastRun"]))?.toLocal(),
|
||||||
|
lastResult: _asStr(j["lastResult"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The time of day the task begins — its first step's, which is what the list
|
||||||
|
/// is ordered by, so the evening's task sits below the morning's.
|
||||||
|
String get firstTime => steps.isEmpty ? "99:99" : steps.first.time;
|
||||||
|
|
||||||
|
/// Whether the last firing reached every charger it was aimed at. The server
|
||||||
|
/// words the outcome as the step it fired and then "n of m sent", so every
|
||||||
|
/// charger answering is the only good case; unknown until it has fired once.
|
||||||
|
bool? get lastRunOk {
|
||||||
|
if (lastRun == null) return null;
|
||||||
|
final m = RegExp(r"(\d+) of (\d+) sent$").firstMatch(lastResult);
|
||||||
|
return m != null && m.group(1) == m.group(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChargingTask copyWith({bool? enabled, DateTime? lastRun, String? lastResult}) => ChargingTask(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
chargers: chargers,
|
||||||
|
steps: steps,
|
||||||
|
zone: zone,
|
||||||
|
days: days,
|
||||||
|
enabled: enabled ?? this.enabled,
|
||||||
|
lastRun: lastRun ?? this.lastRun,
|
||||||
|
lastResult: lastResult ?? this.lastResult,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- home chargers (the user's own wallbox) ---------------------------------
|
// --- home chargers (the user's own wallbox) ---------------------------------
|
||||||
//
|
//
|
||||||
// The garage's import, aimed at the wall: a charger on a connected service
|
// The garage's import, aimed at the wall: a charger on a connected service
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,410 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
import "../format.dart";
|
||||||
|
import "../i18n.dart";
|
||||||
|
import "../main.dart";
|
||||||
|
import "../models.dart";
|
||||||
|
import "../theme.dart";
|
||||||
|
import "../widgets/time_field.dart";
|
||||||
|
|
||||||
|
/// One line of the home-charger scheduler, being written or edited.
|
||||||
|
///
|
||||||
|
/// The charger's own cloud schedule asks four questions and asks them inside one
|
||||||
|
/// charger: on/off, mode, from, to. This asks five, and the fifth is the one that
|
||||||
|
/// makes it a scheduler rather than a second copy of that: *which* chargers. A
|
||||||
|
/// task can name one, several, or none at all — and none means every charger on
|
||||||
|
/// the account, including ones imported after the task was written, because "all
|
||||||
|
/// of them" is a standing wish rather than the list that happened to exist that
|
||||||
|
/// day.
|
||||||
|
///
|
||||||
|
/// A task holds a flow rather than a single command: start at 23:00, ease down
|
||||||
|
/// to 10 A at 01:00, stop at 06:30. That is one intention, so it is one named
|
||||||
|
/// thing with one switch — splitting a charging window across two tasks meant
|
||||||
|
/// naming it twice and remembering to switch off both ends.
|
||||||
|
///
|
||||||
|
/// Pops the saved [ChargingTask].
|
||||||
|
Future<ChargingTask?> showChargingTaskSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
ChargingTask? task,
|
||||||
|
required List<HomeCharger> chargers,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<ChargingTask>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => _ChargingTaskSheet(task: task, chargers: chargers),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the charger can actually be asked to do. Boost and the current limit
|
||||||
|
/// only reach it over the Anker cloud connection; start and stop reach it over
|
||||||
|
/// all three transports. The control mode is a Settings choice, not this form's
|
||||||
|
/// business, so all four are offered and the one that cannot be sent says so
|
||||||
|
/// when it fires — same as the buttons on the page behind this.
|
||||||
|
const List<String> _kActions = ["start", "stop", "limit", "boost"];
|
||||||
|
|
||||||
|
/// One row of the flow while it is being edited. Mutable, because the form edits
|
||||||
|
/// the rows in place; [ChargingStep] is what gets sent.
|
||||||
|
class _StepDraft {
|
||||||
|
String action;
|
||||||
|
String time;
|
||||||
|
double amps;
|
||||||
|
_StepDraft(this.action, this.time, this.amps);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChargingTaskSheet extends StatefulWidget {
|
||||||
|
final ChargingTask? task;
|
||||||
|
final List<HomeCharger> chargers;
|
||||||
|
const _ChargingTaskSheet({this.task, required this.chargers});
|
||||||
|
@override
|
||||||
|
State<_ChargingTaskSheet> createState() => _ChargingTaskSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChargingTaskSheetState extends State<_ChargingTaskSheet> {
|
||||||
|
late final TextEditingController _name =
|
||||||
|
TextEditingController(text: widget.task?.name ?? "");
|
||||||
|
|
||||||
|
/// The flow, as rows the form edits in place. A new task opens with the one
|
||||||
|
/// step most schedules start from, so the common case is a name and a time
|
||||||
|
/// rather than a decision about how many rows to add.
|
||||||
|
late final List<_StepDraft> _steps = (widget.task?.steps ?? const []).isEmpty
|
||||||
|
? [_StepDraft("start", "23:00", 16)]
|
||||||
|
: widget.task!.steps
|
||||||
|
.map((s) => _StepDraft(s.action, s.time, s.amps > 0 ? s.amps : 16))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
/// The chargers this task acts on. Empty is meaningful — it means all of them
|
||||||
|
/// — so the picker has a switch of its own rather than leaving an empty list
|
||||||
|
/// looking like an unfinished form.
|
||||||
|
late bool _allChargers = widget.task == null || widget.task!.chargers.isEmpty;
|
||||||
|
late final Set<String> _picked = {...?widget.task?.chargers};
|
||||||
|
late bool _everyDay = widget.task == null || widget.task!.days.isEmpty;
|
||||||
|
late final Set<int> _days = {...?widget.task?.days};
|
||||||
|
|
||||||
|
bool _saving = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
bool get _editing => (widget.task?.id ?? "").isNotEmpty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_name.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flow of one is a flow, so the last row cannot be removed — an empty task
|
||||||
|
/// would have nothing to fire and the server refuses it anyway.
|
||||||
|
void _addStep() => setState(() => _steps.add(_StepDraft("stop", "06:30", 16)));
|
||||||
|
|
||||||
|
void _removeStep(int i) {
|
||||||
|
if (_steps.length <= 1) return;
|
||||||
|
setState(() => _steps.removeAt(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unticking every day (or every charger) by hand is the same wish as the
|
||||||
|
/// "all" switch, so it lands there rather than leaving a task that acts on
|
||||||
|
/// nothing.
|
||||||
|
void _toggleDay(int day) {
|
||||||
|
setState(() {
|
||||||
|
_everyDay = false;
|
||||||
|
_days.contains(day) ? _days.remove(day) : _days.add(day);
|
||||||
|
if (_days.isEmpty) _everyDay = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleCharger(String id) {
|
||||||
|
setState(() {
|
||||||
|
_allChargers = false;
|
||||||
|
_picked.contains(id) ? _picked.remove(id) : _picked.add(id);
|
||||||
|
if (_picked.isEmpty) _allChargers = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A time still being typed is not a time — [TimeField] says so with an empty
|
||||||
|
/// value — and one unfinished row is enough to make the whole flow unsaveable,
|
||||||
|
/// because the server would otherwise refuse it with a step number the form
|
||||||
|
/// does not show.
|
||||||
|
bool get _canSave =>
|
||||||
|
_name.text.trim().isNotEmpty && _steps.every((s) => s.time.isNotEmpty) && !_saving;
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_canSave) return;
|
||||||
|
setState(() {
|
||||||
|
_saving = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
"name": _name.text.trim(),
|
||||||
|
// The amps ride along on every step so switching one to "limit" and back
|
||||||
|
// does not lose the number that was typed; the server keeps them for the
|
||||||
|
// same reason and ignores them on the actions that have no ceiling.
|
||||||
|
"steps": [
|
||||||
|
for (final s in _steps)
|
||||||
|
{
|
||||||
|
"action": s.action,
|
||||||
|
"time": s.time,
|
||||||
|
"amps": s.action == "limit" ? s.amps : 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"chargers": _allChargers ? <String>[] : _picked.toList(),
|
||||||
|
"days": _everyDay ? <int>[] : _days.toList(),
|
||||||
|
// The time is a wall clock, and the server's is not the one it was set by.
|
||||||
|
// Sending the zone this phone is in is what keeps 23:00 at 23:00 for a
|
||||||
|
// server sitting in another country.
|
||||||
|
"zone": _deviceZone(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
final saved = _editing
|
||||||
|
? await apiClient.updateChargingTask(widget.task!.id, body)
|
||||||
|
: await apiClient.createChargingTask(body);
|
||||||
|
if (mounted) Navigator.pop(context, saved);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _error = "$e");
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The phone's own zone name. Dart has no IANA name to hand — only an offset
|
||||||
|
/// and the platform's abbreviation — so the server is sent what it can read
|
||||||
|
/// and falls back to its own clock when it cannot: an offset is not a zone,
|
||||||
|
/// and a name that is not IANA would be worse than saying nothing.
|
||||||
|
String _deviceZone() {
|
||||||
|
final name = DateTime.now().timeZoneName;
|
||||||
|
return name.contains("/") ? name : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
String _chargerSubtitle(HomeCharger c) =>
|
||||||
|
[c.serial, c.model].where((v) => v.isNotEmpty).join(" · ");
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final muted = DriverVault.muted(context);
|
||||||
|
final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
bottom: DriverVault.sheetBottomInset(context),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t(_editing ? "forms.chargingTask.editTitle" : "forms.chargingTask.title"),
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_error != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
|
||||||
|
),
|
||||||
|
|
||||||
|
Text(t("forms.chargingTask.name"),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
TextField(
|
||||||
|
controller: _name,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
hintText: t("forms.chargingTask.namePlaceholder"),
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
|
||||||
|
// The flow. One row per step, each an action and the time it fires —
|
||||||
|
// read down, they are the night: start at 23:00, ease off at 01:00,
|
||||||
|
// stop at 06:30.
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(t("forms.chargingTask.flow"),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
for (var i = 0; i < _steps.length; i++) _stepRow(context, i, sunken, muted),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _addStep,
|
||||||
|
child: Text(t("forms.chargingTask.addStep")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(t("forms.chargingTask.flowHint"),
|
||||||
|
style: TextStyle(fontSize: 12, color: muted)),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Which chargers. The point of one scheduler for all of them.
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(t("forms.chargingTask.chargers"),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: _allChargers,
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
title: Text(t("forms.chargingTask.allChargers"),
|
||||||
|
style: const TextStyle(fontSize: 14)),
|
||||||
|
subtitle: _allChargers
|
||||||
|
? Text(t("forms.chargingTask.allChargersHint"),
|
||||||
|
style: TextStyle(fontSize: 12, color: muted))
|
||||||
|
: null,
|
||||||
|
onChanged: (on) => setState(() {
|
||||||
|
_allChargers = on ?? true;
|
||||||
|
if (_allChargers) _picked.clear();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (widget.chargers.isEmpty)
|
||||||
|
Text(t("forms.chargingTask.noChargers"),
|
||||||
|
style: TextStyle(fontSize: 12, color: muted)),
|
||||||
|
for (final c in widget.chargers)
|
||||||
|
CheckboxListTile(
|
||||||
|
value: !_allChargers && _picked.contains(c.id),
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
title: Text(c.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 14)),
|
||||||
|
subtitle: _chargerSubtitle(c).isEmpty
|
||||||
|
? null
|
||||||
|
: Text(_chargerSubtitle(c),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: DriverVault.mono(context, size: 11, color: muted)),
|
||||||
|
onChanged: (_) => _toggleCharger(c.id),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Which days.
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(t("forms.chargingTask.days"),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: _everyDay,
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
title: Text(t("forms.chargingTask.everyDay"),
|
||||||
|
style: const TextStyle(fontSize: 14)),
|
||||||
|
onChanged: (on) => setState(() {
|
||||||
|
_everyDay = on ?? true;
|
||||||
|
if (_everyDay) _days.clear();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
// The row starts on whichever day this account reads a week as
|
||||||
|
// starting on — Settings › Appearance › First day of the week,
|
||||||
|
// following the region unless it was answered outright. format.dart
|
||||||
|
// owns the rule for every weekday row in the app.
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
for (final d in weekdaysInOrder())
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(weekdayShortName(d), style: const TextStyle(fontSize: 12)),
|
||||||
|
selected: !_everyDay && _days.contains(d),
|
||||||
|
onSelected: (_) => _toggleDay(d),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(t("common.cancel")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _canSave ? _submit : null,
|
||||||
|
child: Text(_saving ? t("common.saving") : t("common.save")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stepRow(BuildContext context, int i, Color sunken, Color muted) {
|
||||||
|
final s = _steps[i];
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: sunken,
|
||||||
|
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
initialValue: s.action,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration:
|
||||||
|
const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||||
|
items: [
|
||||||
|
for (final a in _kActions)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: a,
|
||||||
|
child: Text(t("charging.scheduler.actions.$a"),
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() => s.action = v ?? s.action),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TimeField(
|
||||||
|
value: s.time,
|
||||||
|
onChanged: (v) => setState(() => s.time = v),
|
||||||
|
),
|
||||||
|
// The last step cannot go: a task with no steps has nothing to
|
||||||
|
// fire, so the control is absent rather than there and refusing.
|
||||||
|
if (_steps.length > 1)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
color: muted,
|
||||||
|
tooltip: t("forms.chargingTask.removeStep"),
|
||||||
|
onPressed: () => _removeStep(i),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// The ceiling, under the one action that takes one.
|
||||||
|
if (s.action == "limit") ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(children: [
|
||||||
|
Text(t("forms.chargingTask.amps"),
|
||||||
|
style: TextStyle(fontSize: 13, color: muted)),
|
||||||
|
const Spacer(),
|
||||||
|
Text("${s.amps.round()} A",
|
||||||
|
style: DriverVault.mono(context, size: 13, weight: FontWeight.w600)),
|
||||||
|
]),
|
||||||
|
Slider(
|
||||||
|
value: s.amps.clamp(6, 32),
|
||||||
|
min: 6,
|
||||||
|
max: 32,
|
||||||
|
divisions: 26,
|
||||||
|
label: "${s.amps.round()} A",
|
||||||
|
onChanged: (v) => setState(() => s.amps = v),
|
||||||
|
),
|
||||||
|
Text(t("forms.chargingTask.ampsHint"),
|
||||||
|
style: TextStyle(fontSize: 11, color: muted)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -257,7 +257,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _email,
|
controller: _email,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
decoration: InputDecoration(labelText: t("login.email"), border: const OutlineInputBorder()),
|
decoration: InputDecoration(
|
||||||
|
labelText: t("login.email"),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
hintText: "you@example.com",
|
||||||
|
),
|
||||||
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
|
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -267,6 +271,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: t("login.password"),
|
labelText: t("login.password"),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
|
hintText: "••••••••",
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
|
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
|
||||||
tooltip: _showPassword ? t("login.hidePassword") : t("login.showPassword"),
|
tooltip: _showPassword ? t("login.hidePassword") : t("login.showPassword"),
|
||||||
|
|||||||
@@ -480,6 +480,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
|||||||
"theme": appSettings.theme,
|
"theme": appSettings.theme,
|
||||||
"locale": appSettings.locale,
|
"locale": appSettings.locale,
|
||||||
"dateFormat": appSettings.dateFormat,
|
"dateFormat": appSettings.dateFormat,
|
||||||
|
"timeFormat": appSettings.timeFormat,
|
||||||
|
"weekStart": appSettings.weekStart,
|
||||||
"currency": appSettings.currency,
|
"currency": appSettings.currency,
|
||||||
"fontSize": appSettings.fontSize,
|
"fontSize": appSettings.fontSize,
|
||||||
};
|
};
|
||||||
@@ -487,6 +489,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
|||||||
theme: patch["theme"],
|
theme: patch["theme"],
|
||||||
locale: patch["locale"],
|
locale: patch["locale"],
|
||||||
dateFormat: patch["dateFormat"],
|
dateFormat: patch["dateFormat"],
|
||||||
|
timeFormat: patch["timeFormat"],
|
||||||
|
weekStart: patch["weekStart"],
|
||||||
currency: patch["currency"],
|
currency: patch["currency"],
|
||||||
fontSize: patch["fontSize"],
|
fontSize: patch["fontSize"],
|
||||||
);
|
);
|
||||||
@@ -498,6 +502,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
|||||||
theme: prev["theme"],
|
theme: prev["theme"],
|
||||||
locale: prev["locale"],
|
locale: prev["locale"],
|
||||||
dateFormat: prev["dateFormat"],
|
dateFormat: prev["dateFormat"],
|
||||||
|
timeFormat: prev["timeFormat"],
|
||||||
|
weekStart: prev["weekStart"],
|
||||||
currency: prev["currency"],
|
currency: prev["currency"],
|
||||||
fontSize: prev["fontSize"],
|
fontSize: prev["fontSize"],
|
||||||
);
|
);
|
||||||
@@ -615,6 +621,63 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
|||||||
child: Text(t("settings.appearance.dateHint", params: {"example": formatDate(DateTime.now())}),
|
child: Text(t("settings.appearance.dateHint", params: {"example": formatDate(DateTime.now())}),
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Beside the date rather than under the region, because it is the same
|
||||||
|
// question asked about the other half of a timestamp.
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(t("settings.appearance.timeFormat"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: appSettings.timeFormat,
|
||||||
|
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem(value: "auto", child: Text(t("settings.appearance.timeAuto"))),
|
||||||
|
DropdownMenuItem(value: "24", child: Text(t("settings.appearance.time24"))),
|
||||||
|
DropdownMenuItem(value: "12", child: Text(t("settings.appearance.time12"))),
|
||||||
|
],
|
||||||
|
onChanged: (v) => v == null ? null : _save({"timeFormat": v}),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
// Thirteen-something rather than now: an example at 09:00 reads the
|
||||||
|
// same in both conventions, which is the one time of day that cannot
|
||||||
|
// show the choice.
|
||||||
|
child: Text(
|
||||||
|
t("settings.appearance.timeExample", params: {
|
||||||
|
"example": formatTime(DateTime(2024, 1, 1, 13, 45)),
|
||||||
|
}),
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Under the clock, as the last of the three questions a region is asked
|
||||||
|
// and the one it is least often asked out loud.
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(t("settings.appearance.weekStart"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: appSettings.weekStart,
|
||||||
|
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem(value: "auto", child: Text(t("settings.appearance.weekAuto"))),
|
||||||
|
DropdownMenuItem(value: "monday", child: Text(t("settings.appearance.weekMonday"))),
|
||||||
|
DropdownMenuItem(value: "sunday", child: Text(t("settings.appearance.weekSunday"))),
|
||||||
|
],
|
||||||
|
onChanged: (v) => v == null ? null : _save({"weekStart": v}),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
// The week as this account will now see it drawn — the clearest
|
||||||
|
// possible example, because the setting has no other visible effect on
|
||||||
|
// this page.
|
||||||
|
child: Text(
|
||||||
|
t("settings.appearance.weekExample", params: {
|
||||||
|
"example": weekdaysInOrder().map(weekdayShortName).join(" "),
|
||||||
|
}),
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(t("settings.appearance.fontSize"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
Text(t("settings.appearance.fontSize"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -1484,20 +1547,42 @@ class _DangerSectionState extends State<_DangerSection> {
|
|||||||
// second "org" scope to edit organization-wide defaults; superadmins manage the
|
// second "org" scope to edit organization-wide defaults; superadmins manage the
|
||||||
// shared layer in the API Server panel, so here it is read-only.
|
// shared layer in the API Server panel, so here it is read-only.
|
||||||
|
|
||||||
enum _FieldType { text, number, password, select }
|
enum _FieldType { text, number, password, select, multiselect }
|
||||||
|
|
||||||
|
/// The control modes the server still offers in a scope, and the ones this
|
||||||
|
/// organization hides from its own users. Named functions rather than closures
|
||||||
|
/// so the field specs below can stay const.
|
||||||
|
List<String> _scopeControlModes(IntegrationScope s) => s.controlModes;
|
||||||
|
List<String> _scopeHiddenControlModes(IntegrationScope s) => s.controlModesDisabled;
|
||||||
|
|
||||||
/// Describes one credential field within an integration card.
|
/// Describes one credential field within an integration card.
|
||||||
class _FieldSpec {
|
class _FieldSpec {
|
||||||
final String key;
|
final String key;
|
||||||
final String labelKey;
|
final String labelKey;
|
||||||
final _FieldType type;
|
final _FieldType type;
|
||||||
final List<(String, String)> options; // (value, labelKey) for selects
|
final List<(String, String)> options; // (value, labelKey) for select/multiselect
|
||||||
final String? placeholder; // literal placeholder
|
final String? placeholder; // literal placeholder
|
||||||
final String? hintKey; // shown below the field when not locked
|
final String? hintKey; // shown below the field when not locked
|
||||||
final String defaultValue;
|
final String defaultValue;
|
||||||
final bool showEffectiveWhenLocked; // controlMode isn't secret: show it locked
|
final bool showEffectiveWhenLocked; // controlMode isn't secret: show it locked
|
||||||
final int? maxLength;
|
final int? maxLength;
|
||||||
|
|
||||||
|
/// Narrows the declared options to those the server still offers in this
|
||||||
|
/// scope — the control-mode hide-list, which is what makes a mode a superadmin
|
||||||
|
/// switched off disappear from an organization's picker, and one an
|
||||||
|
/// organization switched off disappear from its users'. Null leaves every
|
||||||
|
/// declared option standing.
|
||||||
|
final List<String> Function(IntegrationScope)? scopeOptions;
|
||||||
|
|
||||||
|
/// Where a multiselect reads its current value from — a scope list rather than
|
||||||
|
/// a field, because a hide-list is not a cascaded value: it is this layer's
|
||||||
|
/// own instruction to the layers below.
|
||||||
|
final List<String> Function(IntegrationScope)? scopeValue;
|
||||||
|
|
||||||
|
/// Only shown when editing the organization layer. A user has nobody below
|
||||||
|
/// them, so a hide-list would mean nothing there.
|
||||||
|
final bool orgOnly;
|
||||||
|
|
||||||
const _FieldSpec({
|
const _FieldSpec({
|
||||||
required this.key,
|
required this.key,
|
||||||
required this.labelKey,
|
required this.labelKey,
|
||||||
@@ -1508,6 +1593,9 @@ class _FieldSpec {
|
|||||||
this.defaultValue = "",
|
this.defaultValue = "",
|
||||||
this.showEffectiveWhenLocked = false,
|
this.showEffectiveWhenLocked = false,
|
||||||
this.maxLength,
|
this.maxLength,
|
||||||
|
this.scopeOptions,
|
||||||
|
this.scopeValue,
|
||||||
|
this.orgOnly = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1593,6 +1681,7 @@ class _IntegrationsTabState extends State<_IntegrationsTab> {
|
|||||||
hintKey: "settings.integrations.controlModeHint",
|
hintKey: "settings.integrations.controlModeHint",
|
||||||
defaultValue: "off",
|
defaultValue: "off",
|
||||||
showEffectiveWhenLocked: true,
|
showEffectiveWhenLocked: true,
|
||||||
|
scopeOptions: _scopeControlModes,
|
||||||
options: [
|
options: [
|
||||||
("off", "settings.integrations.controlOff"),
|
("off", "settings.integrations.controlOff"),
|
||||||
("mqtt", "settings.integrations.controlCloud"),
|
("mqtt", "settings.integrations.controlCloud"),
|
||||||
@@ -1601,6 +1690,23 @@ class _IntegrationsTabState extends State<_IntegrationsTab> {
|
|||||||
("proxy", "settings.integrations.controlProxy"),
|
("proxy", "settings.integrations.controlProxy"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
// What this organization hides from its own users. Off is absent on
|
||||||
|
// purpose: monitoring only is the fallback, so no layer may take it away.
|
||||||
|
_FieldSpec(
|
||||||
|
key: "controlModesDisabled",
|
||||||
|
labelKey: "settings.integrations.controlModesHidden",
|
||||||
|
type: _FieldType.multiselect,
|
||||||
|
hintKey: "settings.integrations.controlModesHiddenHint",
|
||||||
|
orgOnly: true,
|
||||||
|
scopeOptions: _scopeControlModes,
|
||||||
|
scopeValue: _scopeHiddenControlModes,
|
||||||
|
options: [
|
||||||
|
("mqtt", "settings.integrations.controlCloud"),
|
||||||
|
("modbus", "settings.integrations.controlModbus"),
|
||||||
|
("own", "settings.integrations.controlOwn"),
|
||||||
|
("proxy", "settings.integrations.controlProxy"),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1736,6 +1842,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
|||||||
|
|
||||||
final Map<String, TextEditingController> _controllers = {};
|
final Map<String, TextEditingController> _controllers = {};
|
||||||
final Map<String, String> _selects = {};
|
final Map<String, String> _selects = {};
|
||||||
|
final Map<String, Set<String>> _multi = {}; // multiselect fields (hide-lists)
|
||||||
|
|
||||||
_IntegrationConfig get _c => widget.config;
|
_IntegrationConfig get _c => widget.config;
|
||||||
|
|
||||||
@@ -1743,7 +1850,9 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
for (final f in _c.fields) {
|
for (final f in _c.fields) {
|
||||||
if (f.type != _FieldType.select) _controllers[f.key] = TextEditingController();
|
if (f.type != _FieldType.select && f.type != _FieldType.multiselect) {
|
||||||
|
_controllers[f.key] = TextEditingController();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
@@ -1762,6 +1871,25 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
|||||||
bool get _readOnly => _view?.isSuperadmin ?? false;
|
bool get _readOnly => _view?.isSuperadmin ?? false;
|
||||||
IntegrationScope get _scopeData => _view?.scope(_scopeKey) ?? const IntegrationScope();
|
IntegrationScope get _scopeData => _view?.scope(_scopeKey) ?? const IntegrationScope();
|
||||||
IntegrationField _field(String k) => _scopeData.field(k);
|
IntegrationField _field(String k) => _scopeData.field(k);
|
||||||
|
|
||||||
|
/// The fields this scope actually shows: an org-only field (a hide-list) is
|
||||||
|
/// out of the user scope, and a field whose options the layers above have all
|
||||||
|
/// hidden has nothing left to offer.
|
||||||
|
List<_FieldSpec> get _shownFields => [
|
||||||
|
for (final f in _c.fields)
|
||||||
|
if ((!f.orgOnly || _editingOrg) && (f.options.isEmpty || _optionsFor(f).isNotEmpty)) f,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A field's options after the scope's own list has narrowed them.
|
||||||
|
List<(String, String)> _optionsFor(_FieldSpec f) {
|
||||||
|
if (f.scopeOptions == null) return f.options;
|
||||||
|
final allowed = f.scopeOptions!(_scopeData);
|
||||||
|
if (allowed.isEmpty) return f.options;
|
||||||
|
return [
|
||||||
|
for (final o in f.options)
|
||||||
|
if (allowed.contains(o.$1)) o,
|
||||||
|
];
|
||||||
|
}
|
||||||
bool _locked(String k) => _readOnly || _field(k).locked;
|
bool _locked(String k) => _readOnly || _field(k).locked;
|
||||||
bool get _enabled => _editingOrg ? (_view?.orgEnabled ?? false) : (_view?.enabled ?? false);
|
bool get _enabled => _editingOrg ? (_view?.orgEnabled ?? false) : (_view?.enabled ?? false);
|
||||||
|
|
||||||
@@ -1776,6 +1904,12 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
|||||||
// fields and the secret password are never prefilled.
|
// fields and the secret password are never prefilled.
|
||||||
void _fillForm() {
|
void _fillForm() {
|
||||||
for (final f in _c.fields) {
|
for (final f in _c.fields) {
|
||||||
|
if (f.type == _FieldType.multiselect) {
|
||||||
|
// A hide-list is this layer's own, not something inherited, so it comes
|
||||||
|
// from the scope rather than from a cascaded field.
|
||||||
|
_multi[f.key] = {...?f.scopeValue?.call(_scopeData)};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
final field = _field(f.key);
|
final field = _field(f.key);
|
||||||
String value;
|
String value;
|
||||||
if (f.type == _FieldType.password) {
|
if (f.type == _FieldType.password) {
|
||||||
@@ -1788,7 +1922,11 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
|||||||
value = field.own.isNotEmpty ? field.own : f.defaultValue;
|
value = field.own.isNotEmpty ? field.own : f.defaultValue;
|
||||||
}
|
}
|
||||||
if (f.type == _FieldType.select) {
|
if (f.type == _FieldType.select) {
|
||||||
_selects[f.key] = value;
|
// A stored mode the layers above have since hidden is no longer on
|
||||||
|
// offer, so the picker starts from the default instead of showing a
|
||||||
|
// choice that would not take effect anyway.
|
||||||
|
final options = _optionsFor(f);
|
||||||
|
_selects[f.key] = options.any((o) => o.$1 == value) ? value : f.defaultValue;
|
||||||
} else {
|
} else {
|
||||||
_controllers[f.key]!.text = value;
|
_controllers[f.key]!.text = value;
|
||||||
}
|
}
|
||||||
@@ -1833,9 +1971,19 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
|||||||
_saved = false;
|
_saved = false;
|
||||||
});
|
});
|
||||||
final config = <String, dynamic>{};
|
final config = <String, dynamic>{};
|
||||||
for (final f in _c.fields) {
|
for (final f in _shownFields) {
|
||||||
if (_locked(f.key)) continue;
|
if (_locked(f.key)) continue;
|
||||||
final val = f.type == _FieldType.select ? (_selects[f.key] ?? "") : _controllers[f.key]!.text;
|
final String val;
|
||||||
|
switch (f.type) {
|
||||||
|
case _FieldType.multiselect:
|
||||||
|
// The server stores a hide-list as the same comma-separated string the
|
||||||
|
// API Server panel writes, so both layers round-trip identically.
|
||||||
|
val = _optionsFor(f).map((o) => o.$1).where(_multi[f.key]!.contains).join(",");
|
||||||
|
case _FieldType.select:
|
||||||
|
val = _selects[f.key] ?? "";
|
||||||
|
default:
|
||||||
|
val = _controllers[f.key]!.text;
|
||||||
|
}
|
||||||
if (f.type == _FieldType.password && val.isEmpty) continue;
|
if (f.type == _FieldType.password && val.isEmpty) continue;
|
||||||
config[f.key] = val;
|
config[f.key] = val;
|
||||||
}
|
}
|
||||||
@@ -1995,7 +2143,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Credential fields.
|
// Credential fields.
|
||||||
for (final f in _c.fields) ...[
|
for (final f in _shownFields) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_fieldWidget(f),
|
_fieldWidget(f),
|
||||||
],
|
],
|
||||||
@@ -2083,12 +2231,51 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
|||||||
final field = _field(f.key);
|
final field = _field(f.key);
|
||||||
|
|
||||||
Widget input;
|
Widget input;
|
||||||
if (f.type == _FieldType.select) {
|
final options = _optionsFor(f);
|
||||||
|
if (f.type == _FieldType.multiselect) {
|
||||||
|
final chosen = _multi[f.key] ??= {};
|
||||||
|
input = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (final o in options)
|
||||||
|
InkWell(
|
||||||
|
onTap: locked
|
||||||
|
? null
|
||||||
|
: () => setState(() {
|
||||||
|
if (!chosen.remove(o.$1)) chosen.add(o.$1);
|
||||||
|
}),
|
||||||
|
child: Row(children: [
|
||||||
|
Checkbox(
|
||||||
|
value: chosen.contains(o.$1),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
onChanged: locked
|
||||||
|
? null
|
||||||
|
: (v) => setState(() {
|
||||||
|
if (v ?? false) {
|
||||||
|
chosen.add(o.$1);
|
||||||
|
} else {
|
||||||
|
chosen.remove(o.$1);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(child: Text(t(o.$2), style: const TextStyle(fontSize: 14))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else if (f.type == _FieldType.select) {
|
||||||
|
// A mode hidden above is gone from the list, so a stored value naming one
|
||||||
|
// has nothing to select — fall back to the default rather than crashing
|
||||||
|
// the dropdown on a value it does not carry.
|
||||||
|
final current = _selects[f.key] ?? f.defaultValue;
|
||||||
|
final value = options.any((o) => o.$1 == current) ? current : f.defaultValue;
|
||||||
input = DropdownButtonFormField<String>(
|
input = DropdownButtonFormField<String>(
|
||||||
initialValue: _selects[f.key] ?? f.defaultValue,
|
initialValue: value,
|
||||||
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||||
items: [
|
items: [
|
||||||
for (final o in f.options) DropdownMenuItem(value: o.$1, child: Text(t(o.$2))),
|
for (final o in options) DropdownMenuItem(value: o.$1, child: Text(t(o.$2))),
|
||||||
],
|
],
|
||||||
onChanged: locked ? null : (v) => setState(() => _selects[f.key] = v ?? ""),
|
onChanged: locked ? null : (v) => setState(() => _selects[f.key] = v ?? ""),
|
||||||
);
|
);
|
||||||
@@ -2100,6 +2287,20 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
|||||||
final showDots = isPassword
|
final showDots = isPassword
|
||||||
? field.effective.isNotEmpty
|
? field.effective.isNotEmpty
|
||||||
: locked && !f.showEffectiveWhenLocked;
|
: locked && !f.showEffectiveWhenLocked;
|
||||||
|
// What an inherited field shows when it is empty.
|
||||||
|
//
|
||||||
|
// A locked field is standing in for a value set above the caller, and the
|
||||||
|
// server has already decided which of those may be read: it sends the
|
||||||
|
// secrets back as dots and everything else in the clear. So the
|
||||||
|
// placeholder is that effective value — the thing the field will actually
|
||||||
|
// use — and the example is for the other case, an empty box waiting to be
|
||||||
|
// filled in.
|
||||||
|
//
|
||||||
|
// The example belongs only there. A country field placeholdered "DE" under
|
||||||
|
// the words "inherited from your organization" is not a hint, it is a
|
||||||
|
// wrong answer to the question the user is asking it: which country am I
|
||||||
|
// inheriting?
|
||||||
|
final placeholder = field.locked ? field.effective : f.placeholder;
|
||||||
input = TextField(
|
input = TextField(
|
||||||
controller: _controllers[f.key],
|
controller: _controllers[f.key],
|
||||||
obscureText: isPassword,
|
obscureText: isPassword,
|
||||||
@@ -2111,7 +2312,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
|||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
counterText: "",
|
counterText: "",
|
||||||
hintText: showDots ? "••••••••" : f.placeholder,
|
hintText: showDots ? "••••••••" : placeholder,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter/services.dart";
|
||||||
|
|
||||||
|
import "../format.dart";
|
||||||
|
|
||||||
|
/// A time box that reads on the clock the user chose.
|
||||||
|
///
|
||||||
|
/// The same problem the web app's `components/TimeField.vue` solves, and the
|
||||||
|
/// same shape of answer. Flutter's own `showTimePicker` renders on the *device's*
|
||||||
|
/// locale, which nothing in this app steers: a Settings → Time format of 24-hour
|
||||||
|
/// still met the account with an AM/PM dial, disagreeing with the 00:00 the card
|
||||||
|
/// beside it printed. So the typing is ours — four digits, masked into the clock
|
||||||
|
/// in force, with the meridiem as its own control rather than something to be
|
||||||
|
/// spelled.
|
||||||
|
///
|
||||||
|
/// The value in and out is always 24-hour "HH:MM", which is what the charger's
|
||||||
|
/// schedule commands take and what every caller already had. A half-typed time
|
||||||
|
/// emits "" — a half-typed time is not a time, and emitting the part of it that
|
||||||
|
/// parses would set the charger's schedule to whatever was passed through on the
|
||||||
|
/// way to the value somebody meant.
|
||||||
|
class TimeField extends StatefulWidget {
|
||||||
|
final String value;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
final bool enabled;
|
||||||
|
final String? label;
|
||||||
|
|
||||||
|
const TimeField({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
this.enabled = true,
|
||||||
|
this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TimeField> createState() => _TimeFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimeFieldState extends State<TimeField> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
bool _pm = false;
|
||||||
|
bool _twelve = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_twelve = clockIsTwelveHour();
|
||||||
|
_controller.text = _toText(widget.value);
|
||||||
|
_pm = _toPm(widget.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(TimeField old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
// Switching the setting elsewhere re-lays out what is already in the box,
|
||||||
|
// rather than leaving one field on the old clock.
|
||||||
|
final twelve = clockIsTwelveHour();
|
||||||
|
if (twelve != _twelve) {
|
||||||
|
_twelve = twelve;
|
||||||
|
_controller.text = _toText(widget.value);
|
||||||
|
_pm = _toPm(widget.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only re-render the box when the value it is showing is genuinely a
|
||||||
|
// different time. Half-typed input emits "" — there is no time yet — and
|
||||||
|
// reacting to that would wipe the very digits being typed.
|
||||||
|
if (_toValue(_controller.text, _pm) == widget.value) return;
|
||||||
|
_controller.text = _toText(widget.value);
|
||||||
|
_pm = _toPm(widget.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _pad(int n) => n.toString().padLeft(2, "0");
|
||||||
|
|
||||||
|
/// "HH:MM" → its two numbers, or null for anything that is not a time of day.
|
||||||
|
static (int, int)? _parse(String value) {
|
||||||
|
final m = RegExp(r"^(\d{1,2}):(\d{2})$").firstMatch(value.trim());
|
||||||
|
if (m == null) return null;
|
||||||
|
final h = int.parse(m.group(1)!);
|
||||||
|
final min = int.parse(m.group(2)!);
|
||||||
|
return h > 23 || min > 59 ? null : (h, min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The digits the box shows: the hour as this clock writes it, and the minute.
|
||||||
|
String _toText(String value) {
|
||||||
|
final p = _parse(value);
|
||||||
|
if (p == null) return "";
|
||||||
|
final h = _twelve ? (p.$1 % 12 == 0 ? 12 : p.$1 % 12) : p.$1;
|
||||||
|
return "${_pad(h)}:${_pad(p.$2)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the value sits in the afternoon. Only consulted on a 12-hour clock,
|
||||||
|
/// where the box cannot say it and the toggle has to.
|
||||||
|
bool _toPm(String value) {
|
||||||
|
final p = _parse(value);
|
||||||
|
return p != null && p.$1 >= 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the box and the toggle hold → "HH:MM", or "" while it is still half
|
||||||
|
/// typed.
|
||||||
|
String _toValue(String text, bool pm) {
|
||||||
|
final digits = text.replaceAll(RegExp(r"\D"), "");
|
||||||
|
if (digits.length != 4) return "";
|
||||||
|
var h = int.parse(digits.substring(0, 2));
|
||||||
|
final min = int.parse(digits.substring(2));
|
||||||
|
if (min > 59) return "";
|
||||||
|
if (_twelve) {
|
||||||
|
if (h < 1 || h > 12) return "";
|
||||||
|
h = (h % 12) + (pm ? 12 : 0);
|
||||||
|
} else if (h > 23) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "${_pad(h)}:${_pad(min)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onChanged(String raw) {
|
||||||
|
// The formatter below has already regrouped the digits; this only reports
|
||||||
|
// what they now mean.
|
||||||
|
widget.onChanged(_toValue(raw, _pm));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setPm(bool pm) {
|
||||||
|
setState(() => _pm = pm);
|
||||||
|
widget.onChanged(_toValue(_controller.text, pm));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 76,
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
enabled: widget.enabled,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
inputFormatters: [_ClockMask()],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
counterText: "",
|
||||||
|
hintText: "--:--",
|
||||||
|
labelText: widget.label,
|
||||||
|
),
|
||||||
|
onChanged: _onChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_twelve) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
// A toggle rather than a dropdown: two values, and the one not chosen
|
||||||
|
// is the only other answer there is.
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
style: const ButtonStyle(
|
||||||
|
visualDensity: VisualDensity(horizontal: -3, vertical: -3),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
showSelectedIcon: false,
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: false, label: Text("am")),
|
||||||
|
ButtonSegment(value: true, label: Text("pm")),
|
||||||
|
],
|
||||||
|
selected: {_pm},
|
||||||
|
onSelectionChanged: widget.enabled ? (s) => _setPm(s.first) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Digits regrouped as hh:mm as they are typed. No trailing colon: it appears
|
||||||
|
/// with the next digit, and adding it early only gives backspace something to
|
||||||
|
/// fight with.
|
||||||
|
class _ClockMask extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(TextEditingValue _, TextEditingValue next) {
|
||||||
|
var digits = next.text.replaceAll(RegExp(r"\D"), "");
|
||||||
|
if (digits.length > 4) digits = digits.substring(0, 4);
|
||||||
|
final text =
|
||||||
|
digits.length > 2 ? "${digits.substring(0, 2)}:${digits.substring(2)}" : digits;
|
||||||
|
return TextEditingValue(
|
||||||
|
text: text,
|
||||||
|
selection: TextSelection.collapsed(offset: text.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,8 @@ void main() {
|
|||||||
// Named by their own headings, so the sheet reads like the page.
|
// Named by their own headings, so the sheet reads like the page.
|
||||||
const cardLabels = {
|
const cardLabels = {
|
||||||
"control": "charging.control.title",
|
"control": "charging.control.title",
|
||||||
|
"rfid": "charging.rfid.title",
|
||||||
|
"settings": "charging.modbus.settingsTitle",
|
||||||
"connection": "charging.control.connectionTitle",
|
"connection": "charging.control.connectionTitle",
|
||||||
"readings": "charging.modbus.title",
|
"readings": "charging.modbus.title",
|
||||||
"info": "charging.info.title",
|
"info": "charging.info.title",
|
||||||
|
|||||||
@@ -626,6 +626,8 @@
|
|||||||
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
|
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
|
||||||
"controlMode": "Styringstilstand",
|
"controlMode": "Styringstilstand",
|
||||||
"controlModeHint": "Hvordan DriverVault styrer laderen.",
|
"controlModeHint": "Hvordan DriverVault styrer laderen.",
|
||||||
|
"controlModesHidden": "Skjul for dine brugere",
|
||||||
|
"controlModesHiddenHint": "Tilstande du markerer her forsvinder fra dine brugeres liste og træder ikke længere i kraft for dem. Kun overvågning er altid tilgængelig.",
|
||||||
"controlOff": "Fra (kun overvågning)",
|
"controlOff": "Fra (kun overvågning)",
|
||||||
"controlOwn": "Eget CSMS (fuld styring)",
|
"controlOwn": "Eget CSMS (fuld styring)",
|
||||||
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
|
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
|
||||||
|
|||||||
@@ -625,6 +625,8 @@
|
|||||||
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
|
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
|
||||||
"controlMode": "Control mode",
|
"controlMode": "Control mode",
|
||||||
"controlModeHint": "How DriverVault controls the charger.",
|
"controlModeHint": "How DriverVault controls the charger.",
|
||||||
|
"controlModesHidden": "Hide from your users",
|
||||||
|
"controlModesHiddenHint": "Modes you tick here disappear from your users' picker and stop taking effect for them. Monitoring only is always available.",
|
||||||
"controlOff": "Off (monitoring only)",
|
"controlOff": "Off (monitoring only)",
|
||||||
"controlOwn": "Own CSMS (full control)",
|
"controlOwn": "Own CSMS (full control)",
|
||||||
"controlProxy": "Proxy CSMS (relay + control)",
|
"controlProxy": "Proxy CSMS (relay + control)",
|
||||||
|
|||||||
@@ -632,6 +632,8 @@
|
|||||||
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
|
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
|
||||||
"controlMode": "Tryb sterowania",
|
"controlMode": "Tryb sterowania",
|
||||||
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką.",
|
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką.",
|
||||||
|
"controlModesHidden": "Ukryj przed użytkownikami",
|
||||||
|
"controlModesHiddenHint": "Zaznaczone tryby znikają z listy Twoich użytkowników i przestają dla nich działać. Tylko monitorowanie jest zawsze dostępne.",
|
||||||
"controlOff": "Wyłączone (tylko monitorowanie)",
|
"controlOff": "Wyłączone (tylko monitorowanie)",
|
||||||
"controlOwn": "Własny CSMS (pełne sterowanie)",
|
"controlOwn": "Własny CSMS (pełne sterowanie)",
|
||||||
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
|
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
|
||||||
|
|||||||
@@ -361,6 +361,21 @@ body {
|
|||||||
box-shadow: var(--shadow-focus);
|
box-shadow: var(--shadow-focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Checkbox for the small multi-choice lists (hidden control modes, ...).
|
||||||
|
accent-color keeps the native control and its keyboard behaviour. */
|
||||||
|
.dh-checkbox {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: none;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dh-checkbox:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--shadow-focus);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.dh-label {
|
.dh-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.375rem;
|
margin-bottom: 0.375rem;
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ function showsIntegration(id) {
|
|||||||
|
|
||||||
const anker = ref(null); // resolved view from the server
|
const anker = ref(null); // resolved view from the server
|
||||||
const ankerScope = ref("user"); // "user" | "org" (org admins only)
|
const ankerScope = ref("user"); // "user" | "org" (org admins only)
|
||||||
const ankerForm = ref({ email: "", password: "", country: "", controlMode: "off" });
|
const ankerForm = ref({ email: "", password: "", country: "", controlMode: "off", controlModesDisabled: [] });
|
||||||
const ankerSaving = ref(false);
|
const ankerSaving = ref(false);
|
||||||
const ankerSaved = ref(false);
|
const ankerSaved = ref(false);
|
||||||
const ankerError = ref("");
|
const ankerError = ref("");
|
||||||
@@ -548,17 +548,63 @@ function ankerSourceLabel(k) {
|
|||||||
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
|
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clamp a stored mode to what this scope still offers.
|
||||||
|
function ankerModeInScope(mode) {
|
||||||
|
const modes = ankerScopeData.value.controlModes;
|
||||||
|
if (!mode) return "off";
|
||||||
|
if (modes?.length && !modes.includes(mode)) return "off";
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
function fillAnkerForm() {
|
function fillAnkerForm() {
|
||||||
const f = ankerScopeData.value.fields || {};
|
const f = ankerScopeData.value.fields || {};
|
||||||
ankerForm.value = {
|
ankerForm.value = {
|
||||||
email: f.email?.locked ? "" : f.email?.own || "",
|
email: f.email?.locked ? "" : f.email?.own || "",
|
||||||
password: "",
|
password: "",
|
||||||
country: f.country?.locked ? "" : f.country?.own || "",
|
country: f.country?.locked ? "" : f.country?.own || "",
|
||||||
// controlMode isn't secret, so show the effective value when it's locked.
|
// controlMode isn't secret, so show the effective value when it's locked. A
|
||||||
controlMode: f.controlMode?.locked ? f.controlMode?.effective || "off" : f.controlMode?.own || "off",
|
// mode the layers above have since hidden is no longer on offer, so the
|
||||||
|
// picker starts from monitoring-only rather than showing a dead choice.
|
||||||
|
controlMode: ankerModeInScope(
|
||||||
|
f.controlMode?.locked ? f.controlMode?.effective : f.controlMode?.own,
|
||||||
|
),
|
||||||
|
// Which modes this organization hides from its own users (org scope only).
|
||||||
|
controlModesDisabled: [...(ankerScopeData.value.controlModesDisabled || [])],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The modes this scope may still pick: the server has already removed whatever
|
||||||
|
// the layers above hid. An empty list would leave the picker blank, so fall back
|
||||||
|
// to monitoring-only, which no layer can take away.
|
||||||
|
const ankerControlModeOptions = computed(() => {
|
||||||
|
const modes = ankerScopeData.value.controlModes;
|
||||||
|
return modes?.length ? modes : ["off"];
|
||||||
|
});
|
||||||
|
// The modes an org admin may hide from their users — everything they can choose
|
||||||
|
// themselves, minus off, which is the fallback.
|
||||||
|
const ankerHideableModes = computed(() =>
|
||||||
|
ankerControlModeOptions.value.filter((m) => m !== "off"),
|
||||||
|
);
|
||||||
|
const ankerModeLabels = {
|
||||||
|
off: "controlOff",
|
||||||
|
mqtt: "controlCloud",
|
||||||
|
modbus: "controlModbus",
|
||||||
|
own: "controlOwn",
|
||||||
|
proxy: "controlProxy",
|
||||||
|
};
|
||||||
|
function ankerModeLabel(m) {
|
||||||
|
return t("settings.integrations." + (ankerModeLabels[m] || "controlOff"));
|
||||||
|
}
|
||||||
|
function ankerHidesMode(m) {
|
||||||
|
return ankerForm.value.controlModesDisabled.includes(m);
|
||||||
|
}
|
||||||
|
function toggleAnkerHiddenMode(m, on) {
|
||||||
|
const set = new Set(ankerForm.value.controlModesDisabled);
|
||||||
|
if (on) set.add(m);
|
||||||
|
else set.delete(m);
|
||||||
|
ankerForm.value.controlModesDisabled = ankerHideableModes.value.filter((v) => set.has(v));
|
||||||
|
}
|
||||||
|
|
||||||
// The effective control mode (off | mqtt | modbus | own | proxy) — it gates the
|
// The effective control mode (off | mqtt | modbus | own | proxy) — it gates the
|
||||||
// control panel.
|
// control panel.
|
||||||
const ankerControlMode = computed(() => anker.value?.controlMode || "off");
|
const ankerControlMode = computed(() => anker.value?.controlMode || "off");
|
||||||
@@ -717,6 +763,9 @@ async function saveAnkerSettings() {
|
|||||||
if (k === "password" && !ankerForm.value.password) continue;
|
if (k === "password" && !ankerForm.value.password) continue;
|
||||||
config[k] = ankerForm.value[k];
|
config[k] = ankerForm.value[k];
|
||||||
}
|
}
|
||||||
|
// The hide-list travels as the comma-separated string the server stores; only
|
||||||
|
// a layer with users under it has one.
|
||||||
|
if (ankerEditingOrg.value) config.controlModesDisabled = ankerForm.value.controlModesDisabled.join(",");
|
||||||
try {
|
try {
|
||||||
applyAnkerView(await api.saveAnkerSolix({ scope: ankerScopeKey.value, config }));
|
applyAnkerView(await api.saveAnkerSolix({ scope: ankerScopeKey.value, config }));
|
||||||
ankerSaved.value = true;
|
ankerSaved.value = true;
|
||||||
@@ -1602,15 +1651,33 @@ onBeforeUnmount(() => {
|
|||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("settings.integrations.controlMode") }}</label>
|
<label class="dh-label">{{ t("settings.integrations.controlMode") }}</label>
|
||||||
<select v-model="ankerForm.controlMode" class="dh-input" :disabled="ankerLocked('controlMode')">
|
<select v-model="ankerForm.controlMode" class="dh-input" :disabled="ankerLocked('controlMode')">
|
||||||
<option value="off">{{ t("settings.integrations.controlOff") }}</option>
|
<option v-for="m in ankerControlModeOptions" :key="m" :value="m">{{ ankerModeLabel(m) }}</option>
|
||||||
<option value="mqtt">{{ t("settings.integrations.controlCloud") }}</option>
|
|
||||||
<option value="modbus">{{ t("settings.integrations.controlModbus") }}</option>
|
|
||||||
<option value="own">{{ t("settings.integrations.controlOwn") }}</option>
|
|
||||||
<option value="proxy">{{ t("settings.integrations.controlProxy") }}</option>
|
|
||||||
</select>
|
</select>
|
||||||
<p v-if="ankerField('controlMode').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('controlMode') }}</p>
|
<p v-if="ankerField('controlMode').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('controlMode') }}</p>
|
||||||
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModeHint") }}</p>
|
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModeHint") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- An org admin decides which of the modes left to them their own
|
||||||
|
users get to see. Modes the superadmin already hid are not in
|
||||||
|
the list at all, so they cannot be handed back. -->
|
||||||
|
<div v-if="ankerEditingOrg && ankerHideableModes.length">
|
||||||
|
<label class="dh-label">{{ t("settings.integrations.controlModesHidden") }}</label>
|
||||||
|
<div class="flex flex-col gap-1.5 pt-1">
|
||||||
|
<label
|
||||||
|
v-for="m in ankerHideableModes"
|
||||||
|
:key="m"
|
||||||
|
class="flex items-center gap-2 text-sm text-body"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="dh-checkbox"
|
||||||
|
:checked="ankerHidesMode(m)"
|
||||||
|
@change="toggleAnkerHiddenMode(m, $event.target.checked)"
|
||||||
|
/>
|
||||||
|
<span>{{ ankerModeLabel(m) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModesHiddenHint") }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center gap-2">
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
|||||||
Reference in new issue
Block a user