Compare commits
6
Commits
fe1e314df9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a95131a5 | ||
|
|
28f5fda451 | ||
|
|
dcdc437efa | ||
|
|
6393543bf5 | ||
|
|
3c34b708b9 | ||
|
|
d4b9d0870e |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<title>DriverVault · API Server</title>
|
||||
<script type="module" crossorigin src="/assets/index-BBZfstAT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-_8IdQ1wh.css">
|
||||
<script type="module" crossorigin src="/assets/index-CUgU2Fxs.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cd85xDqG.css">
|
||||
</head>
|
||||
<body>
|
||||
<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.
|
||||
type ankerConfig struct {
|
||||
Email string `json:"email"`
|
||||
@@ -70,6 +116,12 @@ type ankerConfig struct {
|
||||
// ControlMode is the control path: off | mqtt | modbus | own | proxy.
|
||||
// It resolves independently of the credentials, like Country.
|
||||
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:
|
||||
@@ -128,6 +180,14 @@ type ankerResolution struct {
|
||||
available bool // global master switch (plugin enabled in the panel)
|
||||
orgEnabled bool // org gate (default true; gates the org's users)
|
||||
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.
|
||||
@@ -137,7 +197,8 @@ var ankerLayerRank = map[string]int{"global": 1, "org": 2, "user": 3}
|
||||
// pluginSettings blob (read from their user record).
|
||||
func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) ankerResolution {
|
||||
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
|
||||
if who.OrgID != "" {
|
||||
@@ -166,6 +227,21 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
|
||||
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.
|
||||
type layer struct {
|
||||
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
|
||||
// only). The highest layer that sets a recognized value wins; an explicit
|
||||
// "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.source["controlMode"] = "unset"
|
||||
for _, l := range layers {
|
||||
if v := normalizeControlMode(l.c.ControlMode); v != "" {
|
||||
res.eff.ControlMode, res.source["controlMode"] = v, l.name
|
||||
break
|
||||
v := normalizeControlMode(l.c.ControlMode)
|
||||
if v == "" || res.hiddenAt(l.name)[v] {
|
||||
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
|
||||
@@ -212,6 +293,19 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
|
||||
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
|
||||
// for a caller whose editable layer is editable (i.e. the value is set above them).
|
||||
func ankerLockedFor(source, editable string) bool {
|
||||
@@ -301,7 +395,13 @@ func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string
|
||||
}
|
||||
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,
|
||||
"fields": map[string]ankerFieldView{
|
||||
"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),
|
||||
"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.
|
||||
@@ -319,6 +427,7 @@ func (s *Server) ankerView(who *callerIdentity, res ankerResolution) map[string]
|
||||
"orgEnabled": res.orgEnabled,
|
||||
"enabled": res.enabled,
|
||||
"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,
|
||||
"orgId": who.OrgID,
|
||||
"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("password", func(c *ankerConfig, v string) { c.Password = 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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ package pb
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -29,6 +31,7 @@ type Client struct {
|
||||
email string
|
||||
password string
|
||||
token string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
func New(baseURL, email, password string) *Client {
|
||||
@@ -69,6 +72,7 @@ func (c *Client) Reconfigure(baseURL, email, password string) {
|
||||
c.email = email
|
||||
c.password = password
|
||||
c.token = ""
|
||||
c.tokenExp = time.Time{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -120,6 +124,7 @@ func (c *Client) Authenticate(ctx context.Context) error {
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.token = out.Token
|
||||
c.tokenExp = jwtExpiry(out.Token)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -134,8 +139,46 @@ func (c *Client) currentToken() string {
|
||||
return c.token
|
||||
}
|
||||
|
||||
// ensureToken acquires a superuser token when none is cached yet, so that a
|
||||
// superuser call never goes out unauthenticated.
|
||||
// tokenSkew is how long before its stated expiry a cached token is treated as
|
||||
// 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
|
||||
// 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
|
||||
// proceeds as before — the endpoints that need superuser access answer 503 on
|
||||
// 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 {
|
||||
if c.currentToken() != "" || !c.Configured() {
|
||||
if c.tokenLive() || !c.Configured() {
|
||||
return nil
|
||||
}
|
||||
return c.Authenticate(ctx)
|
||||
|
||||
@@ -2,11 +2,13 @@ package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
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
|
||||
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 {
|
||||
@@ -51,11 +58,21 @@ func (f *fakePB) handler() http.Handler {
|
||||
writeTestJSON(w, 400, map[string]any{"message": "Failed to authenticate."})
|
||||
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) {
|
||||
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.guestAttempts++
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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: "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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -44,7 +44,8 @@ const (
|
||||
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 {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
@@ -55,12 +56,12 @@ type SelectOption struct {
|
||||
type ConfigField struct {
|
||||
Key string `json:"key"`
|
||||
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"`
|
||||
Secret bool `json:"secret"` // never echoed back to clients in clear
|
||||
Help string `json:"help,omitempty"`
|
||||
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
|
||||
|
||||
@@ -62,6 +62,31 @@ function expand(p) {
|
||||
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) {
|
||||
busy.value = true;
|
||||
rowNotice[p.name] = "";
|
||||
@@ -224,7 +249,25 @@ const healthClass = (s) =>
|
||||
<label class="dh-label">
|
||||
{{ f.label || f.key }}<span v-if="f.required" class="text-danger"> *</span>
|
||||
</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
|
||||
layer abstains and lower layers (org / user) may choose. -->
|
||||
<option v-if="!f.required" value="">{{ t("plugins.notSet") }}</option>
|
||||
|
||||
@@ -293,6 +293,20 @@ body {
|
||||
border-color: var(--accent);
|
||||
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 {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
# DriverVault all-in-one — production config.
|
||||
# Copy to .env and fill in, then:
|
||||
# docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||
# 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.
|
||||
|
||||
# --- Registry image ----------------------------------------------------------
|
||||
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
|
||||
# --- 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.
|
||||
@@ -57,9 +64,9 @@ API_PORT=8080
|
||||
# --- 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 — 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 -------------------------------------------------
|
||||
@@ -69,23 +76,23 @@ PB_DATA=pb_data
|
||||
#
|
||||
# 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.
|
||||
# defaults, and the container refuses to start without them.
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||
# The bucket. Created on first boot by PocketBase's program, before it serves.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# 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 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
|
||||
# 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
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
# DriverVault all-in-one — production config, SeaweedFS split into its four roles.
|
||||
# Copy to .env and fill in, then:
|
||||
# docker compose -f docker-compose.prod.seaweedfs.split.yml pull
|
||||
# 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.
|
||||
|
||||
# --- Registry image ----------------------------------------------------------
|
||||
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
|
||||
# --- 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.
|
||||
@@ -67,41 +75,39 @@ PB_DATA=pb_data
|
||||
# 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.
|
||||
# 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 seaweedfs-init container.
|
||||
# The bucket. Created on first boot by the S3 gateway's program.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# 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.
|
||||
#
|
||||
# The master, volume and filer containers all mount it at /data, which is the
|
||||
# layout `weed server -dir=/data` writes — so this file and
|
||||
# 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 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
|
||||
# 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 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`.
|
||||
# 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 →
|
||||
@@ -110,7 +116,8 @@ SEAWEED_DATA=seaweed_data
|
||||
# without a restart.
|
||||
#
|
||||
# REQUIRED: weed disables authentication entirely when the password is empty,
|
||||
# and this panel can mint credentials for the bucket.
|
||||
# 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.
|
||||
|
||||
@@ -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"]
|
||||
+45
-24
@@ -15,6 +15,8 @@ scale, upgrade or restart the pieces independently.
|
||||
| File | Use |
|
||||
|---|---|
|
||||
| `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.prod.yml` | **pulls the prebuilt image** from the registry |
|
||||
| `.env.example` / `.env.prod.example` | copy to `.env` for the matching compose file |
|
||||
@@ -104,8 +106,8 @@ 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` | `docker-compose.seaweedfs.yml` |
|
||||
| **SeaweedFS, split into its roles** | `docker-compose.prod.seaweedfs.split.yml` | `docker-compose.seaweedfs.split.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
|
||||
@@ -119,24 +121,42 @@ 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 run the gateway as a **second container**
|
||||
(master, volume, filer and S3 in one process, on its own `seaweed_data` volume)
|
||||
rather than a fourth process under supervisord: keeping the object store in this
|
||||
image, on the volume the files are being moved off, would defeat the point and
|
||||
would mean rebuilding. They also run 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.
|
||||
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. The `.split.` files run 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
|
||||
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. Still none of them inside the image, for the reason above.
|
||||
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
|
||||
@@ -152,16 +172,17 @@ 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 `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. 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.
|
||||
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 `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.
|
||||
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
|
||||
|
||||
@@ -1,261 +1,73 @@
|
||||
name: drivervault-aio
|
||||
|
||||
# Production all-in-one, with SeaweedFS split into its four roles — 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.
|
||||
# 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 pull
|
||||
# 2. docker compose -f docker-compose.prod.seaweedfs.split.yml build
|
||||
# 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 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.
|
||||
#
|
||||
# • 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 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.
|
||||
#
|
||||
# What it costs: five containers beside the all-in-one 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 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.
|
||||
#
|
||||
# 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
|
||||
# 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, both on loopback,
|
||||
# the way the single-gateway file publishes the S3 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.
|
||||
# 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 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.
|
||||
# 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:
|
||||
# --- 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:
|
||||
# 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-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:-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-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:-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-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:
|
||||
# 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 — the same call SEAWEED_S3_BIND makes above,
|
||||
# for the same reason: this is 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.
|
||||
#
|
||||
# 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}"
|
||||
# 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
|
||||
|
||||
drivervault:
|
||||
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}"
|
||||
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
|
||||
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}"
|
||||
@@ -285,43 +97,71 @@ services:
|
||||
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"
|
||||
# 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}"
|
||||
# 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"
|
||||
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:
|
||||
# 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.
|
||||
# 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:
|
||||
# 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"]
|
||||
# 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: 5s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
start_period: 90s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
# Shared by master, volume and filer — the same layout `weed server -dir`
|
||||
# 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:
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
name: drivervault-aio
|
||||
|
||||
# Production all-in-one, with SeaweedFS — 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.
|
||||
# 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 pull
|
||||
# 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
|
||||
@@ -15,79 +23,42 @@ name: drivervault-aio
|
||||
# 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.
|
||||
# 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 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.
|
||||
# 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:
|
||||
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:
|
||||
# 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-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:
|
||||
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}"
|
||||
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
|
||||
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}"
|
||||
@@ -117,39 +88,53 @@ services:
|
||||
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"
|
||||
# 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}"
|
||||
# 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"
|
||||
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:
|
||||
# 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.
|
||||
# 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:
|
||||
# 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"]
|
||||
# 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: 5s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
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:
|
||||
|
||||
+17
-9
@@ -295,15 +295,23 @@ Running it on your own car:
|
||||
- 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
|
||||
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
|
||||
the next line.
|
||||
- Android Auto refuses apps it did not get from the Play Store until you tell it
|
||||
otherwise: in the **Android Auto** settings on the phone, tap the version ten
|
||||
times to unlock **Developer settings**, then turn on **Unknown sources**.
|
||||
- To try it without a car, run Google's
|
||||
[Desktop Head Unit](https://developer.android.com/training/cars/testing/dhu).
|
||||
A debug build accepts any host so the DHU can connect; a release build only
|
||||
accepts the signed hosts the library ships an allowlist for.
|
||||
be reviewed against it. Getting it onto a car at all is the next line.
|
||||
- **A sideloaded build never appears on a real head unit**, and no setting on the
|
||||
phone changes that. Android Auto's **Unknown sources** developer option is the
|
||||
one everybody reaches for here, but it covers media, messaging-notification and
|
||||
parked apps only — [Google's testing
|
||||
guide](https://developer.android.com/training/cars/testing) says in as many
|
||||
words that it does not apply to apps built with the Car App Library. A car will
|
||||
list DriverVault only if the APK was installed *from Play*: an internal testing
|
||||
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
|
||||
|
||||
|
||||
@@ -50,9 +50,13 @@
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
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
|
||||
android:name="androidx.car.app.minCarApiLevel"
|
||||
android:value="1"/>
|
||||
android:value="2"/>
|
||||
<service
|
||||
android:name=".car.DriverVaultCarAppService"
|
||||
android:exported="true">
|
||||
|
||||
@@ -40,7 +40,12 @@ class CarStrings private constructor(
|
||||
|
||||
companion object {
|
||||
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. */
|
||||
fun of(context: Context): CarStrings {
|
||||
|
||||
@@ -661,6 +661,8 @@
|
||||
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
|
||||
"controlMode": "Styringstilstand",
|
||||
"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)",
|
||||
"controlOwn": "Eget CSMS (fuld styring)",
|
||||
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
|
||||
|
||||
@@ -531,6 +531,8 @@
|
||||
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
|
||||
"controlMode": "Control mode",
|
||||
"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)",
|
||||
"controlOwn": "Own CSMS (full control)",
|
||||
"controlProxy": "Proxy CSMS (relay + control)",
|
||||
|
||||
@@ -669,6 +669,8 @@
|
||||
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
|
||||
"controlMode": "Tryb sterowania",
|
||||
"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)",
|
||||
"controlOwn": "Własny CSMS (pełne sterowanie)",
|
||||
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
|
||||
|
||||
@@ -998,7 +998,21 @@ class IntegrationScope {
|
||||
final String editableLayer; // "user" | "org"
|
||||
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) {
|
||||
final raw = j["fields"];
|
||||
@@ -1011,6 +1025,8 @@ class IntegrationScope {
|
||||
return IntegrationScope(
|
||||
editableLayer: _asStr(j["editableLayer"]).isEmpty ? "user" : _asStr(j["editableLayer"]),
|
||||
fields: fields,
|
||||
controlModes: _asStrList(j["controlModes"]),
|
||||
controlModesDisabled: _asStrList(j["controlModesDisabled"]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1547,20 +1547,42 @@ class _DangerSectionState extends State<_DangerSection> {
|
||||
// second "org" scope to edit organization-wide defaults; superadmins manage the
|
||||
// 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.
|
||||
class _FieldSpec {
|
||||
final String key;
|
||||
final String labelKey;
|
||||
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? hintKey; // shown below the field when not locked
|
||||
final String defaultValue;
|
||||
final bool showEffectiveWhenLocked; // controlMode isn't secret: show it locked
|
||||
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({
|
||||
required this.key,
|
||||
required this.labelKey,
|
||||
@@ -1571,6 +1593,9 @@ class _FieldSpec {
|
||||
this.defaultValue = "",
|
||||
this.showEffectiveWhenLocked = false,
|
||||
this.maxLength,
|
||||
this.scopeOptions,
|
||||
this.scopeValue,
|
||||
this.orgOnly = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1656,6 +1681,7 @@ class _IntegrationsTabState extends State<_IntegrationsTab> {
|
||||
hintKey: "settings.integrations.controlModeHint",
|
||||
defaultValue: "off",
|
||||
showEffectiveWhenLocked: true,
|
||||
scopeOptions: _scopeControlModes,
|
||||
options: [
|
||||
("off", "settings.integrations.controlOff"),
|
||||
("mqtt", "settings.integrations.controlCloud"),
|
||||
@@ -1664,6 +1690,23 @@ class _IntegrationsTabState extends State<_IntegrationsTab> {
|
||||
("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"),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1799,6 +1842,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
|
||||
final Map<String, TextEditingController> _controllers = {};
|
||||
final Map<String, String> _selects = {};
|
||||
final Map<String, Set<String>> _multi = {}; // multiselect fields (hide-lists)
|
||||
|
||||
_IntegrationConfig get _c => widget.config;
|
||||
|
||||
@@ -1806,7 +1850,9 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
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();
|
||||
}
|
||||
@@ -1825,6 +1871,25 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
bool get _readOnly => _view?.isSuperadmin ?? false;
|
||||
IntegrationScope get _scopeData => _view?.scope(_scopeKey) ?? const IntegrationScope();
|
||||
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 get _enabled => _editingOrg ? (_view?.orgEnabled ?? false) : (_view?.enabled ?? false);
|
||||
|
||||
@@ -1839,6 +1904,12 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
// fields and the secret password are never prefilled.
|
||||
void _fillForm() {
|
||||
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);
|
||||
String value;
|
||||
if (f.type == _FieldType.password) {
|
||||
@@ -1851,7 +1922,11 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
value = field.own.isNotEmpty ? field.own : f.defaultValue;
|
||||
}
|
||||
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 {
|
||||
_controllers[f.key]!.text = value;
|
||||
}
|
||||
@@ -1896,9 +1971,19 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
_saved = false;
|
||||
});
|
||||
final config = <String, dynamic>{};
|
||||
for (final f in _c.fields) {
|
||||
for (final f in _shownFields) {
|
||||
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;
|
||||
config[f.key] = val;
|
||||
}
|
||||
@@ -2058,7 +2143,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
),
|
||||
|
||||
// Credential fields.
|
||||
for (final f in _c.fields) ...[
|
||||
for (final f in _shownFields) ...[
|
||||
const SizedBox(height: 12),
|
||||
_fieldWidget(f),
|
||||
],
|
||||
@@ -2146,12 +2231,51 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
final field = _field(f.key);
|
||||
|
||||
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>(
|
||||
initialValue: _selects[f.key] ?? f.defaultValue,
|
||||
initialValue: value,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||
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 ?? ""),
|
||||
);
|
||||
|
||||
@@ -626,6 +626,8 @@
|
||||
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
|
||||
"controlMode": "Styringstilstand",
|
||||
"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)",
|
||||
"controlOwn": "Eget CSMS (fuld styring)",
|
||||
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
|
||||
|
||||
@@ -625,6 +625,8 @@
|
||||
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
|
||||
"controlMode": "Control mode",
|
||||
"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)",
|
||||
"controlOwn": "Own CSMS (full control)",
|
||||
"controlProxy": "Proxy CSMS (relay + control)",
|
||||
|
||||
@@ -632,6 +632,8 @@
|
||||
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
|
||||
"controlMode": "Tryb sterowania",
|
||||
"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)",
|
||||
"controlOwn": "Własny CSMS (pełne sterowanie)",
|
||||
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
|
||||
|
||||
@@ -361,6 +361,21 @@ body {
|
||||
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 {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
|
||||
@@ -519,7 +519,7 @@ function showsIntegration(id) {
|
||||
|
||||
const anker = ref(null); // resolved view from the server
|
||||
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 ankerSaved = ref(false);
|
||||
const ankerError = ref("");
|
||||
@@ -548,17 +548,63 @@ function ankerSourceLabel(k) {
|
||||
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() {
|
||||
const f = ankerScopeData.value.fields || {};
|
||||
ankerForm.value = {
|
||||
email: f.email?.locked ? "" : f.email?.own || "",
|
||||
password: "",
|
||||
country: f.country?.locked ? "" : f.country?.own || "",
|
||||
// controlMode isn't secret, so show the effective value when it's locked.
|
||||
controlMode: f.controlMode?.locked ? f.controlMode?.effective || "off" : f.controlMode?.own || "off",
|
||||
// controlMode isn't secret, so show the effective value when it's locked. A
|
||||
// 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
|
||||
// control panel.
|
||||
const ankerControlMode = computed(() => anker.value?.controlMode || "off");
|
||||
@@ -717,6 +763,9 @@ async function saveAnkerSettings() {
|
||||
if (k === "password" && !ankerForm.value.password) continue;
|
||||
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 {
|
||||
applyAnkerView(await api.saveAnkerSolix({ scope: ankerScopeKey.value, config }));
|
||||
ankerSaved.value = true;
|
||||
@@ -1602,15 +1651,33 @@ onBeforeUnmount(() => {
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.integrations.controlMode") }}</label>
|
||||
<select v-model="ankerForm.controlMode" class="dh-input" :disabled="ankerLocked('controlMode')">
|
||||
<option value="off">{{ t("settings.integrations.controlOff") }}</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>
|
||||
<option v-for="m in ankerControlModeOptions" :key="m" :value="m">{{ ankerModeLabel(m) }}</option>
|
||||
</select>
|
||||
<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>
|
||||
</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 class="mt-4 flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user