Compare commits
3
Commits
2b4f4f034d
...
fe1e314df9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe1e314df9 | ||
|
|
9a2a4ab72e | ||
|
|
181f55a849 |
@@ -379,6 +379,22 @@ Copy `.env.example` to `.env` and fill in. Summary:
|
||||
| `OCPP_PUBLIC_URL` | — | canonical `ws(s)://` base to point chargers at |
|
||||
| `PB_BOOTSTRAP` | `true` | run the on-boot schema create/reconcile (leave on across upgrades) |
|
||||
| `DRIVERVAULT_SUPERADMIN_EMAIL` / `_PASSWORD` / `_NAME` | — / — / `Administrator` | first `superadmin`, created on boot when absent |
|
||||
| `PB_S3_ENABLED` | `false` | keep PocketBase's record files in an S3 bucket instead of on its own volume |
|
||||
| `PB_S3_BUCKET` | `drivervault` | the bucket; it must already exist |
|
||||
| `PB_S3_ENDPOINT` | — | e.g. `http://seaweedfs:8333`. No default: in-stack and external gateways are different addresses |
|
||||
| `PB_S3_REGION` | `us-east-1` | SeaweedFS ignores it, PocketBase insists on one |
|
||||
| `PB_S3_ACCESS_KEY` / `PB_S3_SECRET` | — | S3 credentials |
|
||||
| `PB_S3_FORCE_PATH_STYLE` | `true` | path-style bucket addressing; `false` for AWS S3 proper |
|
||||
|
||||
The `PB_S3_*` block is applied by the same on-boot bootstrap that creates the
|
||||
collections, and only when **all** of bucket, endpoint and credentials are set —
|
||||
a half-filled config logs a warning and leaves uploads on the local volume. It
|
||||
writes PocketBase's *Files storage* settings and nothing else: backups stay
|
||||
where they are, and it never turns S3 back *off*, since files already in a bucket
|
||||
are reachable only while PocketBase still points at it. Attachments are served
|
||||
through this server either way ([`internal/api/attachments.go`](internal/api/attachments.go)),
|
||||
so no client can tell the difference. See [`../Docker`](../Docker) for the compose
|
||||
files that set these.
|
||||
|
||||
`PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD`, `PORT` and `CORS_ORIGINS` are
|
||||
still honoured for older deployments; the modern names win when both are set.
|
||||
|
||||
@@ -51,12 +51,31 @@ func main() {
|
||||
// bad service account, must not stop the panel from coming up so a superadmin
|
||||
// can log in and fix the connection.
|
||||
if cfg.Bootstrap && cfg.AdminConfigured() {
|
||||
// Record files go to S3 only when the whole bucket is described. Asking
|
||||
// for it and leaving half of it blank is a misconfiguration worth saying
|
||||
// out loud, not a reason to point PocketBase at nowhere.
|
||||
var s3 *bootstrap.S3Options
|
||||
if cfg.StorageConfigured() {
|
||||
s3 = &bootstrap.S3Options{
|
||||
Enabled: true,
|
||||
Bucket: cfg.S3Bucket,
|
||||
Region: cfg.S3Region,
|
||||
Endpoint: cfg.S3Endpoint,
|
||||
AccessKey: cfg.S3AccessKey,
|
||||
Secret: cfg.S3Secret,
|
||||
ForcePathStyle: cfg.S3ForcePathStyle,
|
||||
}
|
||||
} else if cfg.S3Enabled {
|
||||
log.Println("WARNING: PB_S3_ENABLED is set but the bucket, endpoint or credentials are incomplete — file storage stays on the local volume")
|
||||
}
|
||||
|
||||
bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
if err := bootstrap.Run(bootCtx, client, bootstrap.Options{
|
||||
UsersCollection: cfg.UsersCollection,
|
||||
SuperAdminEmail: cfg.SuperAdminEmail,
|
||||
SuperAdminPassword: cfg.SuperAdminPassword,
|
||||
SuperAdminName: cfg.SuperAdminName,
|
||||
S3: s3,
|
||||
}); err != nil {
|
||||
log.Printf("WARNING: bootstrap failed: %v", err)
|
||||
} else {
|
||||
|
||||
@@ -29,6 +29,24 @@ type Options struct {
|
||||
SuperAdminEmail string
|
||||
SuperAdminPassword string
|
||||
SuperAdminName string
|
||||
|
||||
// S3, when non-nil and Enabled, points PocketBase's record-file storage at
|
||||
// a bucket. Nil is the normal case — a stack started without one of the
|
||||
// SeaweedFS compose overlays keeps its uploads on the pb_data volume.
|
||||
S3 *S3Options
|
||||
}
|
||||
|
||||
// S3Options describes the bucket PocketBase should keep record files in. It
|
||||
// mirrors PocketBase's own settings block one field at a time, so there is
|
||||
// nothing to translate at the wire.
|
||||
type S3Options struct {
|
||||
Enabled bool
|
||||
Bucket string
|
||||
Region string
|
||||
Endpoint string
|
||||
AccessKey string
|
||||
Secret string
|
||||
ForcePathStyle bool
|
||||
}
|
||||
|
||||
// fieldDef is a schema field normalized to a single shape; it is rendered into
|
||||
@@ -190,6 +208,10 @@ func Run(ctx context.Context, client *pb.Client, opts Options) error {
|
||||
if err := ensureSuperAdmin(ctx, client, opts); err != nil {
|
||||
return fmt.Errorf("super-admin: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureFileStorage(ctx, client, opts.S3); err != nil {
|
||||
return fmt.Errorf("file storage: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -433,6 +455,102 @@ func ensureSuperAdmin(ctx context.Context, client *pb.Client, opts Options) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- file storage ----------------------------------------------------------
|
||||
|
||||
// storageSettings is the slice of PocketBase's settings this step owns. The
|
||||
// json names are PocketBase's own (core.S3Config), so the PATCH body is just
|
||||
// this type marshalled back out.
|
||||
type storageSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
AccessKey string `json:"accessKey"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
ForcePathStyle bool `json:"forcePathStyle"`
|
||||
}
|
||||
|
||||
// sameExceptSecret compares everything a read of the settings can be trusted
|
||||
// on. PocketBase masks the stored secret, so a rotation of the secret alone is
|
||||
// invisible from here — changing any other PB_S3_* value forces the write, and
|
||||
// so does editing it in the admin UI.
|
||||
func (s storageSettings) sameExceptSecret(other storageSettings) bool {
|
||||
s.Secret, other.Secret = "", ""
|
||||
return s == other
|
||||
}
|
||||
|
||||
// ensureFileStorage points PocketBase's record-file storage at the configured
|
||||
// bucket, and does nothing at all when no bucket was asked for.
|
||||
//
|
||||
// It never turns S3 *off*: files already written to a bucket are only reachable
|
||||
// while PocketBase is still pointed at it, so dropping the overlay leaves the
|
||||
// setting where it is rather than stranding every existing attachment. Moving
|
||||
// back to local storage is a deliberate act in the admin UI.
|
||||
func ensureFileStorage(ctx context.Context, client *pb.Client, opts *S3Options) error {
|
||||
if opts == nil || !opts.Enabled {
|
||||
return nil // not requested
|
||||
}
|
||||
want := storageSettings{
|
||||
Enabled: true,
|
||||
Bucket: opts.Bucket,
|
||||
Region: opts.Region,
|
||||
Endpoint: opts.Endpoint,
|
||||
AccessKey: opts.AccessKey,
|
||||
Secret: opts.Secret,
|
||||
ForcePathStyle: opts.ForcePathStyle,
|
||||
}
|
||||
|
||||
raw, status, err := client.Raw(ctx, http.MethodGet, "/api/settings", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("read settings: status %d: %s", status, raw)
|
||||
}
|
||||
var current struct {
|
||||
S3 storageSettings `json:"s3"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, ¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if current.S3.sameExceptSecret(want) {
|
||||
log.Printf("bootstrap: • file storage already on S3 (%s)", want.Bucket)
|
||||
} else {
|
||||
// Only the s3 block is sent: everything else in the settings — mail,
|
||||
// backups, rate limits — belongs to whoever set it.
|
||||
raw, status, err = client.Raw(ctx, http.MethodPatch, "/api/settings",
|
||||
map[string]any{"s3": want})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("apply settings: status %d: %s", status, raw)
|
||||
}
|
||||
log.Printf("bootstrap: ✓ file storage → S3 (%s at %s)", want.Bucket, want.Endpoint)
|
||||
}
|
||||
|
||||
testFileStorage(ctx, client)
|
||||
return nil
|
||||
}
|
||||
|
||||
// testFileStorage asks PocketBase to prove it can actually reach the bucket,
|
||||
// and only says so in the log. A failure here means uploads will fail, but the
|
||||
// server still has to come up — the endpoint is fixable from the panel, and a
|
||||
// stack that refuses to boot cannot be fixed from anywhere.
|
||||
func testFileStorage(ctx context.Context, client *pb.Client) {
|
||||
raw, status, err := client.Raw(ctx, http.MethodPost, "/api/settings/test/s3",
|
||||
map[string]any{"filesystem": "storage"})
|
||||
switch {
|
||||
case err != nil:
|
||||
log.Printf("bootstrap: WARNING: S3 storage test failed: %v", err)
|
||||
case status < 200 || status >= 300:
|
||||
log.Printf("bootstrap: WARNING: S3 storage unreachable (status %d): %s", status, raw)
|
||||
default:
|
||||
log.Printf("bootstrap: ✓ S3 storage reachable")
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
func asBool(v any) bool {
|
||||
|
||||
@@ -94,3 +94,39 @@ func TestSchemaConsistency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSameExceptSecret covers the decision ensureFileStorage makes on every
|
||||
// boot: write the settings, or leave them alone. The secret is excluded because
|
||||
// PocketBase masks it on read — comparing it would make every boot a write.
|
||||
func TestSameExceptSecret(t *testing.T) {
|
||||
want := storageSettings{
|
||||
Enabled: true,
|
||||
Bucket: "drivervault",
|
||||
Region: "us-east-1",
|
||||
Endpoint: "http://seaweedfs:8333",
|
||||
AccessKey: "key",
|
||||
Secret: "secret",
|
||||
ForcePathStyle: true,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
current storageSettings
|
||||
same bool
|
||||
}{
|
||||
{"identical", want, true},
|
||||
{"masked secret", func() storageSettings { s := want; s.Secret = ""; return s }(), true},
|
||||
{"rotated secret only", func() storageSettings { s := want; s.Secret = "other"; return s }(), true},
|
||||
{"changed endpoint", func() storageSettings { s := want; s.Endpoint = "http://elsewhere:8333"; return s }(), false},
|
||||
{"changed bucket", func() storageSettings { s := want; s.Bucket = "other"; return s }(), false},
|
||||
{"changed access key", func() storageSettings { s := want; s.AccessKey = "other"; return s }(), false},
|
||||
{"still disabled", func() storageSettings { s := want; s.Enabled = false; return s }(), false},
|
||||
{"path style off", func() storageSettings { s := want; s.ForcePathStyle = false; return s }(), false},
|
||||
{"untouched settings", storageSettings{}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.current.sameExceptSecret(want); got != tc.same {
|
||||
t.Errorf("%s: sameExceptSecret = %v, want %v", tc.name, got, tc.same)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,26 @@ type Config struct {
|
||||
SuperAdminEmail string
|
||||
SuperAdminPassword string
|
||||
SuperAdminName string
|
||||
|
||||
// PocketBase file storage. With S3Enabled set, bootstrap points PocketBase's
|
||||
// "Files storage" at this bucket instead of the pb_data volume; left unset,
|
||||
// uploads stay on disk exactly as they always have. Only *record files* move
|
||||
// — backups are deliberately not touched.
|
||||
//
|
||||
// Nothing here reaches a container unless one of the SeaweedFS compose
|
||||
// overlays is layered on, so an existing stack is unaffected by an upgrade.
|
||||
// S3Endpoint has no default: an in-stack SeaweedFS and one outside it are
|
||||
// different addresses, and guessing either would be worse than not starting.
|
||||
S3Enabled bool
|
||||
S3Bucket string
|
||||
S3Region string
|
||||
S3Endpoint string
|
||||
S3AccessKey string
|
||||
S3Secret string
|
||||
// S3ForcePathStyle keeps bucket names in the path rather than the hostname.
|
||||
// True by default because that is what a self-hosted gateway serves —
|
||||
// virtual-host style would need a DNS entry per bucket.
|
||||
S3ForcePathStyle bool
|
||||
}
|
||||
|
||||
// EnvFile is the .env path (relative to the working directory) that Load reads
|
||||
@@ -66,6 +86,15 @@ func (c Config) AdminConfigured() bool {
|
||||
return c.PocketBaseAdminEmail != "" && c.PocketBaseAdminPassword != ""
|
||||
}
|
||||
|
||||
// StorageConfigured reports whether S3 file storage has been fully specified.
|
||||
// Anything less than all of it counts as "not asked for": a half-filled .env
|
||||
// leaves uploads on the local volume rather than pointing PocketBase at a
|
||||
// bucket it has no way to reach.
|
||||
func (c Config) StorageConfigured() bool {
|
||||
return c.S3Enabled && c.S3Bucket != "" && c.S3Endpoint != "" &&
|
||||
c.S3AccessKey != "" && c.S3Secret != ""
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables, applying sensible
|
||||
// defaults. A .env file, if present in the working directory, is loaded first.
|
||||
func Load() Config {
|
||||
@@ -86,6 +115,13 @@ func Load() Config {
|
||||
SuperAdminEmail: firstEnv("DRIVERVAULT_SUPERADMIN_EMAIL", "SUPERADMIN_EMAIL"),
|
||||
SuperAdminPassword: firstEnv("DRIVERVAULT_SUPERADMIN_PASSWORD", "SUPERADMIN_PASSWORD"),
|
||||
SuperAdminName: getenv("DRIVERVAULT_SUPERADMIN_NAME", "Administrator"),
|
||||
S3Enabled: boolEnv("PB_S3_ENABLED", false),
|
||||
S3Bucket: getenv("PB_S3_BUCKET", "drivervault"),
|
||||
S3Region: getenv("PB_S3_REGION", "us-east-1"),
|
||||
S3Endpoint: strings.TrimRight(getenv("PB_S3_ENDPOINT", ""), "/"),
|
||||
S3AccessKey: getenv("PB_S3_ACCESS_KEY", ""),
|
||||
S3Secret: getenv("PB_S3_SECRET", ""),
|
||||
S3ForcePathStyle: boolEnv("PB_S3_FORCE_PATH_STYLE", true),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# DriverVault all-in-one — production config.
|
||||
# Copy to .env and fill in, then:
|
||||
# docker compose -f docker-compose.prod.s3.yml pull
|
||||
# docker compose -f docker-compose.prod.s3.yml up -d
|
||||
|
||||
# --- Registry image ----------------------------------------------------------
|
||||
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
|
||||
|
||||
# --- PocketBase superuser (required) -----------------------------------------
|
||||
# Created/updated on first boot. The API Server uses these to manage the database.
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# Allowed CORS origin(s) — match your public web URL / WEB_PORT.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. This image serves plain HTTP, so
|
||||
# terminate TLS in a reverse proxy in front of it and set OCPP_PUBLIC_URL to the
|
||||
# public wss:// base the charger should be pointed at. Turning the check off is
|
||||
# for trusted networks only.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||
WEB_PORT=8090
|
||||
PB_PORT=8070
|
||||
API_PORT=8080
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the value below is re-applied on every restart and wins. Set it
|
||||
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||
# own PocketBase and is not meant to be repointed.)
|
||||
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||
# serves it on port 80 inside this container.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# WEBAPP_URL=http://127.0.0.1:80
|
||||
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
# One Docker-managed named volume by default. Set it to an absolute host path
|
||||
# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
|
||||
# image: the API Server keeps no state on disk, so everything it owns (plugin
|
||||
# settings included) is backed up by backing up this one path.
|
||||
PB_DATA=pb_data
|
||||
|
||||
# --- File storage: external S3 -----------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
|
||||
# already exist. For a gateway on this Docker host use
|
||||
# http://host.docker.internal:8333 — the compose file adds the host entry that
|
||||
# makes that name resolve inside the containers.
|
||||
PB_S3_ENDPOINT=http://10.2.1.10:8333
|
||||
PB_S3_BUCKET=drivervault
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,95 @@
|
||||
# DriverVault all-in-one — production config.
|
||||
# Copy to .env and fill in, then:
|
||||
# docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||
# docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||
|
||||
# --- Registry image ----------------------------------------------------------
|
||||
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
|
||||
|
||||
# --- PocketBase superuser (required) -----------------------------------------
|
||||
# Created/updated on first boot. The API Server uses these to manage the database.
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# Allowed CORS origin(s) — match your public web URL / WEB_PORT.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. This image serves plain HTTP, so
|
||||
# terminate TLS in a reverse proxy in front of it and set OCPP_PUBLIC_URL to the
|
||||
# public wss:// base the charger should be pointed at. Turning the check off is
|
||||
# for trusted networks only.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||
WEB_PORT=8090
|
||||
PB_PORT=8070
|
||||
API_PORT=8080
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the value below is re-applied on every restart and wins. Set it
|
||||
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||
# own PocketBase and is not meant to be repointed.)
|
||||
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||
# serves it on port 80 inside this container.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# WEBAPP_URL=http://127.0.0.1:80
|
||||
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
# One Docker-managed named volume by default. Set it to an absolute host path
|
||||
# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
|
||||
# image: the API Server keeps no state on disk, so everything it owns (plugin
|
||||
# settings included) is backed up by backing up this one path.
|
||||
PB_DATA=pb_data
|
||||
|
||||
# --- File storage: SeaweedFS -------------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# The credentials do double duty: they configure the SeaweedFS gateway's single
|
||||
# identity *and* are what PocketBase authenticates with. There are no safe
|
||||
# defaults, and the stack refuses to start without them.
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
|
||||
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
|
||||
# by default; set an absolute host path for a bind mount, the same way PB_DATA
|
||||
# works above. Back it up alongside PB_DATA: from here on the attachments live
|
||||
# here, not in the database volume.
|
||||
SEAWEED_DATA=seaweed_data
|
||||
# The gateway image, pinned so a redeploy months from now brings up the same one.
|
||||
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||
# The S3 port is published on loopback only — the stack reaches the gateway over
|
||||
# the compose network, and this is for tools like aws-cli. Set
|
||||
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
|
||||
# SEAWEED_S3_BIND=127.0.0.1
|
||||
# SEAWEED_S3_PORT=8333
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,129 @@
|
||||
# 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
|
||||
# docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
|
||||
|
||||
# --- Registry image ----------------------------------------------------------
|
||||
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
|
||||
|
||||
# --- PocketBase superuser (required) -----------------------------------------
|
||||
# Created/updated on first boot. The API Server uses these to manage the database.
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# Allowed CORS origin(s) — match your public web URL / WEB_PORT.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. This image serves plain HTTP, so
|
||||
# terminate TLS in a reverse proxy in front of it and set OCPP_PUBLIC_URL to the
|
||||
# public wss:// base the charger should be pointed at. Turning the check off is
|
||||
# for trusted networks only.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||
WEB_PORT=8090
|
||||
PB_PORT=8070
|
||||
API_PORT=8080
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the value below is re-applied on every restart and wins. Set it
|
||||
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||
# own PocketBase and is not meant to be repointed.)
|
||||
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||
# serves it on port 80 inside this container.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# WEBAPP_URL=http://127.0.0.1:80
|
||||
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
# One Docker-managed named volume by default. Set it to an absolute host path
|
||||
# for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||
# PB_DATA — the PocketBase database and its backups. The API Server keeps no
|
||||
# state on disk, so everything it owns (plugin settings included) is backed up
|
||||
# by backing up this one path — together with SEAWEED_DATA below.
|
||||
PB_DATA=pb_data
|
||||
|
||||
# --- File storage: SeaweedFS -------------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# The credentials do double duty: seaweedfs-init writes them into the filer's
|
||||
# IAM store as the identity named "drivervault" *and* they are what PocketBase
|
||||
# authenticates with. There are no safe defaults, and the stack refuses to start
|
||||
# without them. Change them here and restart to rotate: the seed updates the
|
||||
# identity in place rather than adding a second one.
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
|
||||
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
|
||||
# by default; set an absolute host path for a bind mount, the same way PB_DATA
|
||||
# works above. Back it up alongside PB_DATA: from here on the attachments live
|
||||
# here, not in the database volume.
|
||||
#
|
||||
# The master, volume and filer containers all mount it at /data, which is the
|
||||
# layout `weed server -dir=/data` writes — so this file and
|
||||
# docker-compose.prod.seaweedfs.yml are interchangeable on the same volume, with
|
||||
# nothing to migrate either way.
|
||||
SEAWEED_DATA=seaweed_data
|
||||
# The SeaweedFS image, pinned so a redeploy months from now brings up the same
|
||||
# one. All five SeaweedFS containers run it.
|
||||
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||
# The S3 port is published on loopback only — the stack reaches the gateway over
|
||||
# the compose network, and this is for tools like aws-cli. Set
|
||||
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
|
||||
# SEAWEED_S3_BIND=127.0.0.1
|
||||
# SEAWEED_S3_PORT=8333
|
||||
#
|
||||
# The master, volume and filer publish no host port at all. The admin UI below
|
||||
# shows what they would: the volume server in particular serves file content by
|
||||
# id with no authentication, so it stays on the compose network. Reach the
|
||||
# others with `docker compose exec`.
|
||||
|
||||
# --- SeaweedFS admin UI ------------------------------------------------------
|
||||
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
|
||||
# Users, where further S3 identities are created and revoked. They land in the
|
||||
# filer's IAM store, the same one seeded above, and the gateway picks them up
|
||||
# without a restart.
|
||||
#
|
||||
# REQUIRED: weed disables authentication entirely when the password is empty,
|
||||
# and this panel can mint credentials for the bucket.
|
||||
SEAWEED_ADMIN_USER=admin
|
||||
SEAWEED_ADMIN_PASSWORD=
|
||||
# Optional view-only login.
|
||||
SEAWEED_ADMIN_READONLY_USER=
|
||||
SEAWEED_ADMIN_READONLY_PASSWORD=
|
||||
# Bound to localhost by default, the same call SEAWEED_S3_BIND makes: storage
|
||||
# plumbing, not one of the app's own panels. On a remote host that means
|
||||
# unreachable — set 0.0.0.0 and put it behind a reverse proxy.
|
||||
SEAWEED_ADMIN_BIND=127.0.0.1
|
||||
SEAWEED_ADMIN_PORT=23646
|
||||
# Its own small volume: session key and maintenance-task state, no object data.
|
||||
SEAWEED_ADMIN_DATA=seaweed_admin
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Copy to .env and fill in. Used by the Docker-AIO docker-compose.s3.yml.
|
||||
|
||||
# --- Required (no defaults) --------------------------------------------------
|
||||
# PocketBase superuser, also used by the API Server to authenticate.
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# Allowed CORS origin(s) — match your web origin / WEB_PORT.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. This image serves plain HTTP:
|
||||
# either terminate TLS in front of it and set OCPP_PUBLIC_URL to the public
|
||||
# wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||
WEB_PORT=8090
|
||||
PB_PORT=8070
|
||||
# API Server + its embedded web panel (served at the API root, http://host:8080/).
|
||||
API_PORT=8080
|
||||
|
||||
# --- Build args (optional) ---------------------------------------------------
|
||||
# Leave empty so the browser uses same-origin /api (proxied by nginx).
|
||||
VITE_API_BASE=
|
||||
# PocketBase version. The Dockerfile already pins one; set this only to build a
|
||||
# different version. Leaving it commented out keeps the pin (an empty value here
|
||||
# is passed through as-is and would resolve the latest release at build time).
|
||||
#PB_VERSION=0.39.11
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the value below is re-applied on every restart and wins. Set it
|
||||
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||
# own PocketBase and is not meant to be repointed.)
|
||||
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||
# serves it on port 80 inside this container.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# WEBAPP_URL=http://127.0.0.1:80
|
||||
|
||||
# --- File storage: external S3 -----------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
|
||||
# already exist. For a gateway on this Docker host use
|
||||
# http://host.docker.internal:8333 — the compose file adds the host entry that
|
||||
# makes that name resolve inside the containers.
|
||||
PB_S3_ENDPOINT=http://host.docker.internal:8333
|
||||
PB_S3_BUCKET=drivervault
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copy to .env and fill in. Used by the Docker-AIO docker-compose.seaweedfs.yml.
|
||||
|
||||
# --- Required (no defaults) --------------------------------------------------
|
||||
# PocketBase superuser, also used by the API Server to authenticate.
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# Allowed CORS origin(s) — match your web origin / WEB_PORT.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. This image serves plain HTTP:
|
||||
# either terminate TLS in front of it and set OCPP_PUBLIC_URL to the public
|
||||
# wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||
WEB_PORT=8090
|
||||
PB_PORT=8070
|
||||
# API Server + its embedded web panel (served at the API root, http://host:8080/).
|
||||
API_PORT=8080
|
||||
|
||||
# --- Build args (optional) ---------------------------------------------------
|
||||
# Leave empty so the browser uses same-origin /api (proxied by nginx).
|
||||
VITE_API_BASE=
|
||||
# PocketBase version. The Dockerfile already pins one; set this only to build a
|
||||
# different version. Leaving it commented out keeps the pin (an empty value here
|
||||
# is passed through as-is and would resolve the latest release at build time).
|
||||
#PB_VERSION=0.39.11
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the value below is re-applied on every restart and wins. Set it
|
||||
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||
# own PocketBase and is not meant to be repointed.)
|
||||
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||
# serves it on port 80 inside this container.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# WEBAPP_URL=http://127.0.0.1:80
|
||||
|
||||
# --- File storage: SeaweedFS -------------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# The credentials do double duty: they configure the SeaweedFS gateway's single
|
||||
# identity *and* are what PocketBase authenticates with. There are no safe
|
||||
# defaults, and the stack refuses to start without them.
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# The gateway image, pinned so a rebuild months from now brings up the same one.
|
||||
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||
# Host port for the S3 API, so aws-cli and friends can reach it while developing.
|
||||
# SEAWEED_S3_PORT=8333
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Copy to .env and fill in. Used by the Docker-AIO
|
||||
# docker-compose.seaweedfs.split.yml — the same stack as .env.seaweedfs.example,
|
||||
# with SeaweedFS running as separate master / volume / filer / S3 containers
|
||||
# plus the SeaweedFS admin UI.
|
||||
|
||||
# --- Required (no defaults) --------------------------------------------------
|
||||
# PocketBase superuser, also used by the API Server to authenticate.
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# Allowed CORS origin(s) — match your web origin / WEB_PORT.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. This image serves plain HTTP:
|
||||
# either terminate TLS in front of it and set OCPP_PUBLIC_URL to the public
|
||||
# wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||
WEB_PORT=8090
|
||||
PB_PORT=8070
|
||||
# API Server + its embedded web panel (served at the API root, http://host:8080/).
|
||||
API_PORT=8080
|
||||
|
||||
# --- Build args (optional) ---------------------------------------------------
|
||||
# Leave empty so the browser uses same-origin /api (proxied by nginx).
|
||||
VITE_API_BASE=
|
||||
# PocketBase version. The Dockerfile already pins one; set this only to build a
|
||||
# different version. Leaving it commented out keeps the pin (an empty value here
|
||||
# is passed through as-is and would resolve the latest release at build time).
|
||||
#PB_VERSION=0.39.11
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the value below is re-applied on every restart and wins. Set it
|
||||
# here to make a change permanent. (POCKETBASE_URL is fixed to this container's
|
||||
# own PocketBase and is not meant to be repointed.)
|
||||
# WEBAPP_URL the Web App address the panel status page probes; nginx
|
||||
# serves it on port 80 inside this container.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# WEBAPP_URL=http://127.0.0.1:80
|
||||
|
||||
# --- File storage: SeaweedFS -------------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# The credentials do double duty: seaweedfs-init writes them into the filer's
|
||||
# IAM store as the identity named "drivervault" *and* they are what PocketBase
|
||||
# authenticates with. There are no safe defaults, and the stack refuses to start
|
||||
# without them. Change them here and restart to rotate: the seed updates the
|
||||
# identity in place rather than adding a second one.
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# The SeaweedFS image, pinned so a rebuild months from now brings up the same
|
||||
# one. All five SeaweedFS containers run it.
|
||||
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||
|
||||
# --- SeaweedFS admin UI ------------------------------------------------------
|
||||
# http://localhost:23646 — cluster topology, volumes, buckets, and
|
||||
# Object Store → Users, where further S3 identities are created and revoked.
|
||||
# They land in the filer's IAM store, the same one seeded above, and the gateway
|
||||
# picks them up without a restart.
|
||||
#
|
||||
# REQUIRED: weed disables authentication entirely when the password is empty,
|
||||
# and this panel can mint credentials for the bucket.
|
||||
SEAWEED_ADMIN_USER=admin
|
||||
SEAWEED_ADMIN_PASSWORD=
|
||||
# SEAWEED_ADMIN_PORT=23646
|
||||
|
||||
# --- SeaweedFS host ports (optional; defaults shown) -------------------------
|
||||
# Published for aws-cli, `weed shell` and poking around while developing. The
|
||||
# stack itself reaches every one of these over the compose network.
|
||||
# Note SEAWEED_VOLUME_PORT: the volume server serves file content by id with NO
|
||||
# authentication, so do not carry this mapping over to a shared machine. It
|
||||
# lands on 8081 because API_PORT already has 8080.
|
||||
# SEAWEED_MASTER_PORT=9333
|
||||
# SEAWEED_VOLUME_PORT=8081
|
||||
# SEAWEED_FILER_PORT=8888
|
||||
# SEAWEED_S3_PORT=8333
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -88,6 +88,107 @@ volume; set `PB_DATA` to an absolute host path in the prod file for a bind mount
|
||||
> container. Set the corresponding environment variables to change them
|
||||
> permanently.
|
||||
|
||||
## File storage (SeaweedFS / S3)
|
||||
|
||||
Uploaded files — document scans, service and refill receipts, workshop invoices,
|
||||
part photos — live inside `pb_data` by default, next to the database. Two further
|
||||
compose files put them in an S3 bucket instead, so the blobs and the database can
|
||||
be sized, backed up and moved independently. Nothing else changes: an attachment has
|
||||
always been fetched through the API Server (`GET /api/service-records/{id}/file`),
|
||||
never from a storage URL, so the Web App, the phone app and the Home Assistant
|
||||
plugin cannot tell the difference.
|
||||
|
||||
Each shape is one self-contained compose file — nothing to layer, nothing to
|
||||
remember — with an `.env` example of the same name:
|
||||
|
||||
| Shape | From the registry | From source |
|
||||
|---|---|---|
|
||||
| **Local storage** — the default, unchanged | `docker-compose.prod.yml` | `docker-compose.yml` |
|
||||
| **SeaweedFS beside the image** | `docker-compose.prod.seaweedfs.yml` | `docker-compose.seaweedfs.yml` |
|
||||
| **SeaweedFS, split into its roles** | `docker-compose.prod.seaweedfs.split.yml` | `docker-compose.seaweedfs.split.yml` |
|
||||
| **An S3 endpoint elsewhere** | `docker-compose.prod.s3.yml` | `docker-compose.s3.yml` |
|
||||
|
||||
So `docker-compose.prod.seaweedfs.yml` is configured from
|
||||
`.env.prod.seaweedfs.example`, `docker-compose.s3.yml` from `.env.s3.example`,
|
||||
and so on:
|
||||
|
||||
```sh
|
||||
cp .env.prod.seaweedfs.example .env # then edit it — PB_S3_* have no defaults
|
||||
docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||
docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||
```
|
||||
|
||||
Set `PB_S3_ACCESS_KEY` and `PB_S3_SECRET` first — both storage files refuse to
|
||||
start without them. The 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.
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
Identities work differently there, and it matters. SeaweedFS reads credentials
|
||||
from, in descending priority: an `-s3.config` file, the filer's IAM store, then
|
||||
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — and a higher source *replaces* a
|
||||
lower one rather than adding to it. The single-gateway files use the env vars,
|
||||
which is why nothing else may write identities there: the first user added in a
|
||||
panel would displace PocketBase's key. So in the split files `seaweedfs-init`
|
||||
seeds PocketBase's identity into the filer's store instead — the same store the
|
||||
admin UI writes — and the gateway runs with no config file at all. One source of
|
||||
truth, PocketBase's key visible in the panel beside every other, and new keys
|
||||
picked up without a restart. Rotating `PB_S3_SECRET` in `.env` and restarting
|
||||
updates that identity in place.
|
||||
|
||||
Set `SEAWEED_ADMIN_PASSWORD`: `weed admin` serves the panel with no
|
||||
authentication when it is empty, and a panel that can mint bucket credentials is
|
||||
the bucket. 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.
|
||||
|
||||
Switching between `docker-compose.seaweedfs.yml` and its `.split.` twin needs no
|
||||
migration: master, volume and filer share one `/data` mount, which is exactly
|
||||
the layout `weed server -dir=/data` writes.
|
||||
|
||||
On every boot the API Server's bootstrap writes PocketBase's *Files storage*
|
||||
settings from those variables, then asks PocketBase to prove it can reach the
|
||||
bucket. Watch for it in the log:
|
||||
|
||||
```
|
||||
[api] bootstrap: ✓ file storage → S3 (drivervault at http://seaweedfs:8333)
|
||||
[api] bootstrap: ✓ S3 storage reachable
|
||||
```
|
||||
|
||||
A boot that finds the settings already correct logs `• file storage already on S3`
|
||||
and writes nothing.
|
||||
|
||||
Two things to know before turning it on:
|
||||
|
||||
- **Existing files are not migrated.** PocketBase copies nothing when the setting
|
||||
flips, so attachments uploaded before the switch stop resolving. Copy
|
||||
`pb_data/storage/<collectionId>/<recordId>/<file>` into the bucket root, keeping
|
||||
that layout, *before* enabling it — or start from a stack with no attachments.
|
||||
- **Going back to the plain compose file is not an off switch.** It leaves
|
||||
PocketBase pointed at
|
||||
the bucket, deliberately: files already written there are reachable only while
|
||||
it is. Move them back and turn it off in PocketBase's own admin UI. For the same
|
||||
reason a rotation of `PB_S3_SECRET` alone is invisible to the bootstrap —
|
||||
PocketBase masks the stored secret on read — so change another `PB_S3_*` value
|
||||
alongside it, or set it in the admin UI.
|
||||
|
||||
## Charger control (OCPP)
|
||||
|
||||
Chargers in own/proxy mode dial in to `/ocpp/{serial}` on the **API Server port
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
name: drivervault-aio
|
||||
|
||||
# Production all-in-one, with external S3 — pulls the prebuilt image from the
|
||||
# registry instead of building. Self-contained: one file, no overlays.
|
||||
# Everything an operator needs to set lives in .env.
|
||||
#
|
||||
# 1. cp .env.prod.s3.example .env (then edit it — PB_S3_* especially)
|
||||
# 2. docker compose -f docker-compose.prod.s3.yml pull
|
||||
# 3. docker compose -f docker-compose.prod.s3.yml up -d
|
||||
#
|
||||
# This is docker-compose.prod.yml pointed at an S3 endpoint that already exists
|
||||
# somewhere else — its own host, another compose project, or any S3-compatible
|
||||
# service. PocketBase keeps its record files — document scans, service and
|
||||
# refill receipts, workshop invoices, part photos — in that bucket instead of on
|
||||
# the pb_data volume. The database and PocketBase's own backups stay on PB_DATA.
|
||||
# Clients cannot tell the difference: an attachment has always been fetched
|
||||
# through the API Server, never from a storage URL.
|
||||
#
|
||||
# The bucket must already exist, and nothing here runs the gateway. For a
|
||||
# SeaweedFS that comes up with the container, use
|
||||
# docker-compose.prod.seaweedfs.yml.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
#
|
||||
# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API
|
||||
# Server creates any missing collections and the DriverVault super-admin from
|
||||
# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent.
|
||||
|
||||
services:
|
||||
drivervault:
|
||||
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}"
|
||||
container_name: drivervault-aio
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||
# Harmless when the endpoint is somewhere else entirely.
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||
# inside this container, so plain localhost:8090 would never answer.
|
||||
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||
# screen permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||
# add collections or fields the server needs, and a stack that skips the
|
||||
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||
# app_settings, the collection holding the plugin settings, which it
|
||||
# creates on demand because it cannot serve the plugin panel without it.
|
||||
# Every other schema change still depends on this flag. Turn it off only
|
||||
# for a database you know already matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||
# charger can only connect when TLS is terminated in front of it (set
|
||||
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||
# with OCPP_REQUIRE_TLS=false.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# supervisord passes these through to the API Server, whose bootstrap
|
||||
# writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on PB_DATA. The bucket must already exist: nothing here
|
||||
# creates it.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
|
||||
ports:
|
||||
- "${WEB_PORT:-8090}:80" # Web App
|
||||
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||
volumes:
|
||||
# The only volume — named by default; set PB_DATA to a host path in .env
|
||||
# for a bind mount. The API Server keeps no state on disk, so everything
|
||||
# it owns (plugin settings included) is in here.
|
||||
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||
healthcheck:
|
||||
# All three processes must answer. Declared here as well as in the image so
|
||||
# the check is visible, and works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
@@ -0,0 +1,330 @@
|
||||
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.
|
||||
#
|
||||
# 1. cp .env.prod.seaweedfs.split.example .env (then edit it)
|
||||
# 2. docker compose -f docker-compose.prod.seaweedfs.split.yml pull
|
||||
# 3. docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
|
||||
#
|
||||
# This is docker-compose.prod.seaweedfs.yml with the storage layer taken apart.
|
||||
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
|
||||
# process; here each is its own container, plus the SeaweedFS admin UI. What
|
||||
# that buys:
|
||||
#
|
||||
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
|
||||
# where S3 identities are created and revoked without touching a file;
|
||||
# • per-role restart, upgrade and Prometheus metrics;
|
||||
# • room to add a second volume server later, on this host or another.
|
||||
#
|
||||
# What it costs: five containers 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
#
|
||||
# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API
|
||||
# Server creates any missing collections and the DriverVault super-admin from
|
||||
# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent.
|
||||
|
||||
services:
|
||||
# --- 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}"
|
||||
container_name: drivervault-aio
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# PocketBase — inside this container — is the process that reads and
|
||||
# writes the objects, so the gateway has to be serving first, and the
|
||||
# bucket has to exist before the bootstrap points PocketBase at it.
|
||||
seaweedfs-s3:
|
||||
condition: service_healthy
|
||||
seaweedfs-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||
# inside this container, so plain localhost:8090 would never answer.
|
||||
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||
# screen permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||
# add collections or fields the server needs, and a stack that skips the
|
||||
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||
# app_settings, the collection holding the plugin settings, which it
|
||||
# creates on demand because it cannot serve the plugin panel without it.
|
||||
# Every other schema change still depends on this flag. Turn it off only
|
||||
# for a database you know already matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||
# charger can only connect when TLS is terminated in front of it (set
|
||||
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||
# with OCPP_REQUIRE_TLS=false.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# supervisord passes these through to the API Server, whose bootstrap
|
||||
# writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on PB_DATA.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
# The gateway's service name: a server-to-server call inside the compose
|
||||
# network.
|
||||
PB_S3_ENDPOINT: "http://seaweedfs-s3:8333"
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||
PB_S3_FORCE_PATH_STYLE: "true"
|
||||
ports:
|
||||
- "${WEB_PORT:-8090}:80" # Web App
|
||||
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||
volumes:
|
||||
# The only volume — named by default; set PB_DATA to a host path in .env
|
||||
# for a bind mount. The API Server keeps no state on disk, so everything
|
||||
# it owns (plugin settings included) is in here.
|
||||
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||
healthcheck:
|
||||
# All three processes must answer. Declared here as well as in the image so
|
||||
# the check is visible, and works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
# Shared by master, volume and filer — the same layout `weed server -dir`
|
||||
# writes, so this file and docker-compose.prod.seaweedfs.yml can swap places
|
||||
# on it.
|
||||
seaweed_data:
|
||||
# The admin UI's own session key and maintenance-task state. Small, and no
|
||||
# part of the object store.
|
||||
seaweed_admin:
|
||||
@@ -0,0 +1,155 @@
|
||||
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.
|
||||
#
|
||||
# 1. cp .env.prod.seaweedfs.example .env (then edit it)
|
||||
# 2. docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||
# 3. docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||
#
|
||||
# This is docker-compose.prod.yml plus an S3 object store: PocketBase keeps its
|
||||
# record files — document scans, service and refill receipts, workshop invoices,
|
||||
# part photos — in a SeaweedFS bucket instead of on the pb_data volume next to
|
||||
# the database. The database and PocketBase's own backups stay on PB_DATA.
|
||||
# Clients cannot tell the difference: an attachment has always been fetched
|
||||
# through the API Server, never from a storage URL.
|
||||
#
|
||||
# SeaweedFS runs as a second container beside the all-in-one, not as a fourth
|
||||
# process inside it: keeping the object store in that image, on the volume the
|
||||
# files are being moved off, would defeat the point and would mean rebuilding.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
#
|
||||
# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API
|
||||
# Server creates any missing collections and the DriverVault super-admin from
|
||||
# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent.
|
||||
|
||||
services:
|
||||
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}"
|
||||
container_name: drivervault-aio
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# PocketBase — inside this container — is the process that reads and
|
||||
# writes the objects, so the gateway has to be serving first, and the
|
||||
# bucket has to exist before the bootstrap points PocketBase at it.
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
seaweedfs-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||
# inside this container, so plain localhost:8090 would never answer.
|
||||
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||
# screen permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||
# add collections or fields the server needs, and a stack that skips the
|
||||
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||
# app_settings, the collection holding the plugin settings, which it
|
||||
# creates on demand because it cannot serve the plugin panel without it.
|
||||
# Every other schema change still depends on this flag. Turn it off only
|
||||
# for a database you know already matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||
# charger can only connect when TLS is terminated in front of it (set
|
||||
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||
# with OCPP_REQUIRE_TLS=false.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# supervisord passes these through to the API Server, whose bootstrap
|
||||
# writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on PB_DATA.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
# The service name: a server-to-server call inside the compose network.
|
||||
PB_S3_ENDPOINT: "http://seaweedfs:8333"
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||
PB_S3_FORCE_PATH_STYLE: "true"
|
||||
ports:
|
||||
- "${WEB_PORT:-8090}:80" # Web App
|
||||
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||
volumes:
|
||||
# The only volume — named by default; set PB_DATA to a host path in .env
|
||||
# for a bind mount. The API Server keeps no state on disk, so everything
|
||||
# it owns (plugin settings included) is in here.
|
||||
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||
healthcheck:
|
||||
# All three processes must answer. Declared here as well as in the image so
|
||||
# the check is visible, and works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
seaweed_data:
|
||||
@@ -0,0 +1,107 @@
|
||||
name: drivervault-aio
|
||||
|
||||
# Single all-in-one container, with external S3: PocketBase + API Server + Web
|
||||
# App (nginx) in one image, storing files in an S3 endpoint that already exists
|
||||
# somewhere else. Self-contained — one file, nothing to layer.
|
||||
#
|
||||
# cp .env.s3.example .env (then edit it — PB_S3_* especially)
|
||||
# docker compose -f docker-compose.s3.yml up -d --build
|
||||
#
|
||||
# The build context is the project root so the Dockerfile can reach both
|
||||
# "API Server/" and "Web App/".
|
||||
#
|
||||
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
|
||||
# document scans, service and refill receipts, workshop invoices, part photos —
|
||||
# in that bucket instead of on the pb_data volume next to the database. The
|
||||
# database and PocketBase's own backups stay on pb_data. Clients cannot tell the
|
||||
# difference: an attachment has always been fetched through the API Server,
|
||||
# never from a storage URL.
|
||||
#
|
||||
# The bucket must already exist, and nothing here runs the gateway. For a
|
||||
# SeaweedFS that comes up with the container, use docker-compose.seaweedfs.yml.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
|
||||
services:
|
||||
drivervault:
|
||||
build:
|
||||
# Project root (one level up from this compose file).
|
||||
context: ..
|
||||
dockerfile: Docker-AIO/Dockerfile
|
||||
args:
|
||||
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
|
||||
- VITE_API_BASE=${VITE_API_BASE:-}
|
||||
# Bare name = pass through only when set in the environment, so an unset
|
||||
# PB_VERSION leaves the Dockerfile pin in place instead of overriding it
|
||||
# with an empty string (which would resolve "latest" at build time).
|
||||
- PB_VERSION
|
||||
image: drivervault-aio
|
||||
container_name: drivervault-aio
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||
# Harmless when the endpoint is somewhere else entirely.
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||
# inside this container, so plain localhost:8090 would never answer.
|
||||
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||
# screen permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||
# add collections or fields the server needs, and a stack that skips the
|
||||
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||
# app_settings, the collection holding the plugin settings, which it
|
||||
# creates on demand because it cannot serve the plugin panel without it.
|
||||
# Every other schema change still depends on this flag. Turn it off only
|
||||
# for a database you know already matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||
# charger can only connect when TLS is terminated in front of it (set
|
||||
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||
# with OCPP_REQUIRE_TLS=false.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# supervisord passes these through to the API Server, whose bootstrap
|
||||
# writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on pb_data. The bucket must already exist: nothing here
|
||||
# creates it.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
|
||||
ports:
|
||||
- "${WEB_PORT:-8090}:80" # Web App
|
||||
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||
volumes:
|
||||
# The only volume: the API Server keeps no state on disk, so everything
|
||||
# it owns — plugin settings included — lives in the database.
|
||||
- pb_data:/pb/pb_data
|
||||
healthcheck:
|
||||
# All three processes must answer. Declared here as well as in the image so
|
||||
# the check is visible, and works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
@@ -0,0 +1,319 @@
|
||||
name: drivervault-aio
|
||||
|
||||
# Single all-in-one container, with SeaweedFS split into its four roles:
|
||||
# PocketBase + API Server + Web App (nginx) in one image, beside master, volume,
|
||||
# filer, S3 gateway and the SeaweedFS admin UI as separate containers.
|
||||
# Self-contained — one file, nothing to layer.
|
||||
#
|
||||
# cp .env.seaweedfs.split.example .env (then edit it)
|
||||
# docker compose -f docker-compose.seaweedfs.split.yml up -d --build
|
||||
#
|
||||
# The build context is the project root so the Dockerfile can reach both
|
||||
# "API Server/" and "Web App/".
|
||||
#
|
||||
# This is docker-compose.seaweedfs.yml with the storage layer taken apart.
|
||||
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
|
||||
# process; here each is its own container. What that buys:
|
||||
#
|
||||
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
|
||||
# where S3 identities are created and revoked without touching a file;
|
||||
# • per-role restart, upgrade and Prometheus metrics;
|
||||
# • room to add a second volume server later, on this host or another.
|
||||
#
|
||||
# What it costs: five containers beside the all-in-one instead of one, and five
|
||||
# healthchecks to keep the boot order honest. If none of the above is wanted,
|
||||
# use docker-compose.seaweedfs.yml — the S3 behaviour is identical.
|
||||
#
|
||||
# None of them run inside the all-in-one image, for the same reason the single
|
||||
# gateway does not: keeping the object store in that image, on the volume the
|
||||
# files are being moved off, would defeat the point and would mean rebuilding.
|
||||
#
|
||||
# The on-disk layout is deliberately the same as the single-process file's:
|
||||
# master, volume and filer share one /data mount, exactly as `weed server -dir`
|
||||
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
|
||||
# filename overlap). So the two files are interchangeable on the same volume,
|
||||
# with no migration either way. A *second* volume server would need its own.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
|
||||
services:
|
||||
# --- SeaweedFS: master -----------------------------------------------------
|
||||
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
|
||||
# the other roles are told to reach it by, so it must be the service name and
|
||||
# not the container IP the process would otherwise detect.
|
||||
seaweedfs-master:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-aio-seaweedfs-master
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
|
||||
-volumeSizeLimitMB=1024 -metricsPort=9324
|
||||
volumes:
|
||||
- seaweed_data:/data
|
||||
ports:
|
||||
# Master UI / API. Useful while developing; the admin UI below covers the
|
||||
# same ground with a nicer face.
|
||||
- "${SEAWEED_MASTER_PORT:-9333}:9333"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: volume server ---------------------------------------------
|
||||
# Where the bytes actually land. -max=0 lets it size itself from free disk
|
||||
# rather than the default cap of 8 volumes.
|
||||
seaweedfs-volume:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-aio-seaweedfs-volume
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
|
||||
-port=8080 -dir=/data -max=0 -metricsPort=9325
|
||||
depends_on:
|
||||
seaweedfs-master:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- seaweed_data:/data
|
||||
ports:
|
||||
# This port serves file content by file id with NO authentication — the S3
|
||||
# credentials do not apply to it. Publish it only where you would be
|
||||
# willing to publish the bucket itself.
|
||||
- "${SEAWEED_VOLUME_PORT:-8081}:8080"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: filer ------------------------------------------------------
|
||||
# Gives the flat volume store a directory tree — buckets, object keys — and
|
||||
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
|
||||
# embedded leveldb goes; without it that would be the container's working
|
||||
# directory, and the identities would not survive a recreate.
|
||||
seaweedfs-filer:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-aio-seaweedfs-filer
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
|
||||
-port=8888 -defaultStoreDir=/data -metricsPort=9326
|
||||
depends_on:
|
||||
seaweedfs-volume:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- seaweed_data:/data
|
||||
ports:
|
||||
- "${SEAWEED_FILER_PORT:-8888}:8888"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: bucket and identity seed ----------------------------------
|
||||
# Runs once and exits, before the gateway starts. Two jobs:
|
||||
#
|
||||
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
|
||||
# 2. write PocketBase's S3 identity into the filer's IAM store.
|
||||
#
|
||||
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
|
||||
# docker-compose.seaweedfs.yml does. Those env vars are the *lowest* priority
|
||||
# credential source in SeaweedFS: they are read only while the filer's store is
|
||||
# empty, so the first identity added in the admin UI would silently displace
|
||||
# them and lock PocketBase out. Seeding the store the admin UI itself writes
|
||||
# leaves one source of truth, and the key PocketBase uses appears under
|
||||
# Object Store → Users like any other.
|
||||
#
|
||||
# Both commands update in place, so every later boot re-applies the values from
|
||||
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
|
||||
# reaches the gateway.
|
||||
#
|
||||
# The closing grep is the gate: an empty IAM store means the gateway would come
|
||||
# up in its allow-anyone default, so this fails loudly instead and the gateway
|
||||
# below never starts. No `|| true` here, deliberately.
|
||||
seaweedfs-init:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-aio-seaweedfs-init
|
||||
restart: "no"
|
||||
depends_on:
|
||||
seaweedfs-filer:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Passed as env and expanded by the shell inside the container, so the
|
||||
# secret stays out of the container's argv.
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -e
|
||||
printf '%s\n' \
|
||||
"s3.bucket.create -name $$PB_S3_BUCKET" \
|
||||
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
|
||||
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
|
||||
echo "s3.configure" \
|
||||
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
|
||||
| grep -q "$$PB_S3_ACCESS_KEY"
|
||||
|
||||
# --- SeaweedFS: S3 gateway -------------------------------------------------
|
||||
# The endpoint PocketBase talks to. No -config file: with only -filer given,
|
||||
# credentials come from the filer's IAM store, which is what lets the admin UI
|
||||
# add and revoke identities without a restart. A config file would take
|
||||
# priority over that store and make the admin UI's users inert.
|
||||
seaweedfs-s3:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-aio-seaweedfs-s3
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
|
||||
-metricsPort=9327
|
||||
depends_on:
|
||||
seaweedfs-filer:
|
||||
condition: service_healthy
|
||||
# Never serve before an identity exists — see seaweedfs-init above.
|
||||
seaweedfs-init:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
# The stack reaches the gateway over the compose network; this is here so
|
||||
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
|
||||
- "${SEAWEED_S3_PORT:-8333}:8333"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8333/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: admin UI ---------------------------------------------------
|
||||
# http://localhost:23646 — cluster topology, volumes, buckets, maintenance
|
||||
# tasks, and Object Store → Users, where S3 access keys are minted and revoked.
|
||||
# It finds the filer through the master, so -master is all it needs.
|
||||
#
|
||||
# An unauthenticated panel that can mint credentials for the bucket *is* the
|
||||
# bucket, so the password is required rather than defaulted — weed leaves auth
|
||||
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
|
||||
# flag, which keeps it off the process command line. -dataDir persists the
|
||||
# session key and the maintenance-task settings.
|
||||
seaweedfs-admin:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-aio-seaweedfs-admin
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
|
||||
-metricsPort=9328
|
||||
depends_on:
|
||||
seaweedfs-master:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
|
||||
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
|
||||
volumes:
|
||||
- seaweed_admin:/data
|
||||
ports:
|
||||
- "${SEAWEED_ADMIN_PORT:-23646}:23646"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
drivervault:
|
||||
build:
|
||||
# Project root (one level up from this compose file).
|
||||
context: ..
|
||||
dockerfile: Docker-AIO/Dockerfile
|
||||
args:
|
||||
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
|
||||
- VITE_API_BASE=${VITE_API_BASE:-}
|
||||
# Bare name = pass through only when set in the environment, so an unset
|
||||
# PB_VERSION leaves the Dockerfile pin in place instead of overriding it
|
||||
# with an empty string (which would resolve "latest" at build time).
|
||||
- PB_VERSION
|
||||
image: drivervault-aio
|
||||
container_name: drivervault-aio
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# PocketBase — inside this container — is the process that reads and
|
||||
# writes the objects, so the gateway has to be serving first, and the
|
||||
# bucket has to exist before the bootstrap points PocketBase at it.
|
||||
seaweedfs-s3:
|
||||
condition: service_healthy
|
||||
seaweedfs-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||
# inside this container, so plain localhost:8090 would never answer.
|
||||
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||
# screen permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||
# add collections or fields the server needs, and a stack that skips the
|
||||
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||
# app_settings, the collection holding the plugin settings, which it
|
||||
# creates on demand because it cannot serve the plugin panel without it.
|
||||
# Every other schema change still depends on this flag. Turn it off only
|
||||
# for a database you know already matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||
# charger can only connect when TLS is terminated in front of it (set
|
||||
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||
# with OCPP_REQUIRE_TLS=false.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# supervisord passes these through to the API Server, whose bootstrap
|
||||
# writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on pb_data.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
# The gateway's service name: a server-to-server call inside the compose
|
||||
# network.
|
||||
PB_S3_ENDPOINT: "http://seaweedfs-s3:8333"
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||
PB_S3_FORCE_PATH_STYLE: "true"
|
||||
ports:
|
||||
- "${WEB_PORT:-8090}:80" # Web App
|
||||
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||
volumes:
|
||||
# The only volume: the API Server keeps no state on disk, so everything
|
||||
# it owns — plugin settings included — lives in the database.
|
||||
- pb_data:/pb/pb_data
|
||||
healthcheck:
|
||||
# All three processes must answer. Declared here as well as in the image so
|
||||
# the check is visible, and works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
# Shared by master, volume and filer — the same layout `weed server -dir`
|
||||
# writes, so this file and docker-compose.seaweedfs.yml can swap places on it.
|
||||
seaweed_data:
|
||||
# The admin UI's own session key and maintenance-task state. Small, and no
|
||||
# part of the object store.
|
||||
seaweed_admin:
|
||||
@@ -0,0 +1,160 @@
|
||||
name: drivervault-aio
|
||||
|
||||
# Single all-in-one container, with SeaweedFS: PocketBase + API Server + Web App
|
||||
# (nginx) in one image, plus an S3 object store beside it. Self-contained — one
|
||||
# file, nothing to layer.
|
||||
#
|
||||
# cp .env.seaweedfs.example .env (then edit it)
|
||||
# docker compose -f docker-compose.seaweedfs.yml up -d --build
|
||||
#
|
||||
# The build context is the project root so the Dockerfile can reach both
|
||||
# "API Server/" and "Web App/".
|
||||
#
|
||||
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
|
||||
# document scans, service and refill receipts, workshop invoices, part photos —
|
||||
# in a SeaweedFS bucket instead of on the pb_data volume next to the database.
|
||||
# The database and PocketBase's own backups stay on pb_data. Clients cannot tell
|
||||
# the difference: an attachment has always been fetched through the API Server,
|
||||
# never from a storage URL.
|
||||
#
|
||||
# SeaweedFS runs as a second container beside the all-in-one, not as a fourth
|
||||
# process inside it: keeping the object store in that image, on the volume the
|
||||
# files are being moved off, would defeat the point and would mean rebuilding.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
|
||||
services:
|
||||
seaweedfs:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-aio-seaweedfs
|
||||
restart: unless-stopped
|
||||
# One process, four roles: master, volume, filer and the S3 gateway. -dir is
|
||||
# the only state it keeps.
|
||||
command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024
|
||||
environment:
|
||||
# SeaweedFS falls back to these when started without an -s3.config file,
|
||||
# and configuring one identity is what takes the S3 gateway out of its
|
||||
# default allow-anyone mode. The same credentials PocketBase authenticates
|
||||
# with below — one pair to set, in .env.
|
||||
AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||
AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||
volumes:
|
||||
# From here on the attachments live here, not on pb_data.
|
||||
- seaweed_data:/data
|
||||
ports:
|
||||
# The stack reaches the gateway over the compose network; this is here so
|
||||
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
|
||||
- "${SEAWEED_S3_PORT:-8333}:8333"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
seaweedfs-init:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-aio-seaweedfs-init
|
||||
# Runs once and exits. PocketBase never issues a CreateBucket of its own and
|
||||
# SeaweedFS will not conjure one on first upload, so something has to.
|
||||
# Creating a bucket that already exists is a no-op, so every later boot
|
||||
# passes straight through.
|
||||
restart: "no"
|
||||
depends_on:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
# `|| true` so a restart is never blocked by the shell's exit status: this
|
||||
# step is best-effort, and a gateway that is genuinely unreachable is
|
||||
# reported by the API Server's own S3 check at boot, with the reason.
|
||||
command:
|
||||
- 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true'
|
||||
|
||||
drivervault:
|
||||
build:
|
||||
# Project root (one level up from this compose file).
|
||||
context: ..
|
||||
dockerfile: Docker-AIO/Dockerfile
|
||||
args:
|
||||
# Empty -> bundle uses same-origin "/api", proxied internally by nginx.
|
||||
- VITE_API_BASE=${VITE_API_BASE:-}
|
||||
# Bare name = pass through only when set in the environment, so an unset
|
||||
# PB_VERSION leaves the Dockerfile pin in place instead of overriding it
|
||||
# with an empty string (which would resolve "latest" at build time).
|
||||
- PB_VERSION
|
||||
image: drivervault-aio
|
||||
container_name: drivervault-aio
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# PocketBase — inside this container — is the process that reads and
|
||||
# writes the objects, so the gateway has to be serving first, and the
|
||||
# bucket has to exist before the bootstrap points PocketBase at it.
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
seaweedfs-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
# Probed by the panel status page. nginx serves the Web App on port 80
|
||||
# inside this container, so plain localhost:8090 would never answer.
|
||||
# Override WEBAPP_URL in .env to make a change from the panel's Web App
|
||||
# screen permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://127.0.0.1:80}"
|
||||
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||
# add collections or fields the server needs, and a stack that skips the
|
||||
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||
# app_settings, the collection holding the plugin settings, which it
|
||||
# creates on demand because it cannot serve the plugin panel without it.
|
||||
# Every other schema change still depends on this flag. Turn it off only
|
||||
# for a database you know already matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). This image serves plain HTTP, so a
|
||||
# charger can only connect when TLS is terminated in front of it (set
|
||||
# OCPP_PUBLIC_URL to the public wss:// base) — or, on a trusted network,
|
||||
# with OCPP_REQUIRE_TLS=false.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# supervisord passes these through to the API Server, whose bootstrap
|
||||
# writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on pb_data.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
# The service name: a server-to-server call inside the compose network.
|
||||
PB_S3_ENDPOINT: "http://seaweedfs:8333"
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||
PB_S3_FORCE_PATH_STYLE: "true"
|
||||
ports:
|
||||
- "${WEB_PORT:-8090}:80" # Web App
|
||||
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||
- "${API_PORT:-8080}:8080" # API Server + panel (root /) + /ocpp/{serial}
|
||||
volumes:
|
||||
# The only volume: the API Server keeps no state on disk, so everything
|
||||
# it owns — plugin settings included — lives in the database.
|
||||
- pb_data:/pb/pb_data
|
||||
healthcheck:
|
||||
# All three processes must answer. Declared here as well as in the image so
|
||||
# the check is visible, and works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
seaweed_data:
|
||||
@@ -75,11 +75,3 @@ VITE_API_BASE=
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- Public hostname + TLS (docker-compose.tls.yml) --------------------------
|
||||
# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this
|
||||
# host from the internet, with ports 80 and 443 reaching it; the certificate is
|
||||
# issued automatically on first boot. Setting it also points chargers at
|
||||
# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on.
|
||||
DV_DOMAIN=
|
||||
DV_ACME_EMAIL=
|
||||
|
||||
@@ -97,11 +97,3 @@ API_BIND=127.0.0.1
|
||||
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
||||
# settings included) is backed up by backing up this one path.
|
||||
PB_DATA=pb_data
|
||||
|
||||
# --- Public hostname + TLS (docker-compose.tls.yml) --------------------------
|
||||
# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this
|
||||
# host from the internet, with ports 80 and 443 reaching it; the certificate is
|
||||
# issued automatically on first boot. Setting it also points chargers at
|
||||
# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on.
|
||||
DV_DOMAIN=
|
||||
DV_ACME_EMAIL=
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# DriverVault — production stack config.
|
||||
# Copy to .env and fill in, then:
|
||||
# docker compose -f docker-compose.prod.s3.yml pull
|
||||
# docker compose -f docker-compose.prod.s3.yml up -d
|
||||
|
||||
# --- Registry images ---------------------------------------------------------
|
||||
# Defaults point at the internal registry; override to pin a tag or use a mirror.
|
||||
PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest
|
||||
API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest
|
||||
WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest
|
||||
|
||||
# --- PocketBase superuser ----------------------------------------------------
|
||||
# Created/updated on the PocketBase container's first boot. The API Server uses
|
||||
# these same credentials to manage the database. REQUIRED.
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave blank to skip and
|
||||
# create the first user by hand. This is the account you log in to the web app
|
||||
# with — distinct from the PocketBase superuser above.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# --- API Server --------------------------------------------------------------
|
||||
# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT).
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
AUTH_USERS_COLLECTION=users
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. Keep the default and terminate
|
||||
# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the
|
||||
# public wss:// base the charger should be pointed at (deriving it from request
|
||||
# headers is unreliable behind a proxy). Turning the check off is for trusted
|
||||
# networks only.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||
# thing in the Anker app, then read the frames back out of the container log.
|
||||
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||
# seconds.
|
||||
ANKER_MQTT_FRAME_LOG=
|
||||
|
||||
# The charger can dial either door: the API Server port directly, or the Web
|
||||
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||
# is whichever host the panel itself was reached on, which is the Web App.
|
||||
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||
# whatever the client said it was.
|
||||
TRUST_FORWARDED_PROTO=false
|
||||
|
||||
# --- Ports -------------------------------------------------------------------
|
||||
# WEB_PORT is the public front door (bound on all interfaces).
|
||||
WEB_PORT=8090
|
||||
# PocketBase admin UI and the API panel are bound to localhost only by default.
|
||||
# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network.
|
||||
PB_PORT=8070
|
||||
PB_BIND=127.0.0.1
|
||||
API_PORT=8080
|
||||
API_BIND=127.0.0.1
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the values below are re-applied on every restart and win. Set
|
||||
# them here to make a change permanent.
|
||||
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||
# the bundled pocketbase service; set it to reach one
|
||||
# outside this stack.
|
||||
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||
# a container-to-container call, so it must be reachable
|
||||
# from the API Server, not from your browser.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
# One Docker-managed named volume by default. To store it on a host path
|
||||
# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
|
||||
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
||||
# settings included) is backed up by backing up this one path.
|
||||
PB_DATA=pb_data
|
||||
|
||||
# --- File storage: external S3 -----------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
|
||||
# already exist. For a gateway on this Docker host use
|
||||
# http://host.docker.internal:8333 — the compose file adds the host entry that
|
||||
# makes that name resolve inside the containers.
|
||||
PB_S3_ENDPOINT=http://10.2.1.10:8333
|
||||
PB_S3_BUCKET=drivervault
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,131 @@
|
||||
# DriverVault — production stack config.
|
||||
# Copy to .env and fill in, then:
|
||||
# docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||
# docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||
|
||||
# --- Registry images ---------------------------------------------------------
|
||||
# Defaults point at the internal registry; override to pin a tag or use a mirror.
|
||||
PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest
|
||||
API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest
|
||||
WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest
|
||||
|
||||
# --- PocketBase superuser ----------------------------------------------------
|
||||
# Created/updated on the PocketBase container's first boot. The API Server uses
|
||||
# these same credentials to manage the database. REQUIRED.
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave blank to skip and
|
||||
# create the first user by hand. This is the account you log in to the web app
|
||||
# with — distinct from the PocketBase superuser above.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# --- API Server --------------------------------------------------------------
|
||||
# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT).
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
AUTH_USERS_COLLECTION=users
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. Keep the default and terminate
|
||||
# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the
|
||||
# public wss:// base the charger should be pointed at (deriving it from request
|
||||
# headers is unreliable behind a proxy). Turning the check off is for trusted
|
||||
# networks only.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||
# thing in the Anker app, then read the frames back out of the container log.
|
||||
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||
# seconds.
|
||||
ANKER_MQTT_FRAME_LOG=
|
||||
|
||||
# The charger can dial either door: the API Server port directly, or the Web
|
||||
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||
# is whichever host the panel itself was reached on, which is the Web App.
|
||||
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||
# whatever the client said it was.
|
||||
TRUST_FORWARDED_PROTO=false
|
||||
|
||||
# --- Ports -------------------------------------------------------------------
|
||||
# WEB_PORT is the public front door (bound on all interfaces).
|
||||
WEB_PORT=8090
|
||||
# PocketBase admin UI and the API panel are bound to localhost only by default.
|
||||
# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network.
|
||||
PB_PORT=8070
|
||||
PB_BIND=127.0.0.1
|
||||
API_PORT=8080
|
||||
API_BIND=127.0.0.1
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the values below are re-applied on every restart and win. Set
|
||||
# them here to make a change permanent.
|
||||
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||
# the bundled pocketbase service; set it to reach one
|
||||
# outside this stack.
|
||||
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||
# a container-to-container call, so it must be reachable
|
||||
# from the API Server, not from your browser.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
# One Docker-managed named volume by default. To store it on a host path
|
||||
# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
|
||||
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
||||
# settings included) is backed up by backing up this one path.
|
||||
PB_DATA=pb_data
|
||||
|
||||
# --- File storage: SeaweedFS -------------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# The credentials do double duty: they configure the SeaweedFS gateway's single
|
||||
# identity *and* are what PocketBase authenticates with. There are no safe
|
||||
# defaults, and the stack refuses to start without them.
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
|
||||
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
|
||||
# by default; set an absolute host path for a bind mount, the same way PB_DATA
|
||||
# works above. Back it up alongside PB_DATA: from here on the attachments live
|
||||
# here, not in the database volume.
|
||||
SEAWEED_DATA=seaweed_data
|
||||
# The gateway image, pinned so a redeploy months from now brings up the same one.
|
||||
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||
# The S3 port is published on loopback only — the stack reaches the gateway over
|
||||
# the compose network, and this is for tools like aws-cli. Set
|
||||
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
|
||||
# SEAWEED_S3_BIND=127.0.0.1
|
||||
# SEAWEED_S3_PORT=8333
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,166 @@
|
||||
# DriverVault — production stack config, SeaweedFS split into its four roles.
|
||||
# Copy to .env and fill in, then:
|
||||
# docker compose -f docker-compose.prod.seaweedfs.split.yml pull
|
||||
# docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
|
||||
|
||||
# --- Registry images ---------------------------------------------------------
|
||||
# Defaults point at the internal registry; override to pin a tag or use a mirror.
|
||||
PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest
|
||||
API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest
|
||||
WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest
|
||||
|
||||
# --- PocketBase superuser ----------------------------------------------------
|
||||
# Created/updated on the PocketBase container's first boot. The API Server uses
|
||||
# these same credentials to manage the database. REQUIRED.
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave blank to skip and
|
||||
# create the first user by hand. This is the account you log in to the web app
|
||||
# with — distinct from the PocketBase superuser above.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# --- API Server --------------------------------------------------------------
|
||||
# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT).
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
AUTH_USERS_COLLECTION=users
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. Keep the default and terminate
|
||||
# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the
|
||||
# public wss:// base the charger should be pointed at (deriving it from request
|
||||
# headers is unreliable behind a proxy). Turning the check off is for trusted
|
||||
# networks only.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||
# thing in the Anker app, then read the frames back out of the container log.
|
||||
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||
# seconds.
|
||||
ANKER_MQTT_FRAME_LOG=
|
||||
|
||||
# The charger can dial either door: the API Server port directly, or the Web
|
||||
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||
# is whichever host the panel itself was reached on, which is the Web App.
|
||||
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||
# whatever the client said it was.
|
||||
TRUST_FORWARDED_PROTO=false
|
||||
|
||||
# --- Ports -------------------------------------------------------------------
|
||||
# WEB_PORT is the public front door (bound on all interfaces).
|
||||
WEB_PORT=8090
|
||||
# PocketBase admin UI and the API panel are bound to localhost only by default.
|
||||
# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network — which is
|
||||
# what a stack running on a remote host needs, behind a reverse proxy.
|
||||
PB_PORT=8070
|
||||
PB_BIND=127.0.0.1
|
||||
API_PORT=8080
|
||||
API_BIND=127.0.0.1
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the values below are re-applied on every restart and win. Set
|
||||
# them here to make a change permanent.
|
||||
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||
# the bundled pocketbase service; set it to reach one
|
||||
# outside this stack.
|
||||
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||
# a container-to-container call, so it must be reachable
|
||||
# from the API Server, not from your browser.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
# One Docker-managed named volume by default. To store it on a host path
|
||||
# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||
# PB_DATA — the PocketBase database and its backups. The API Server keeps no
|
||||
# state on disk, so everything it owns (plugin settings included) is backed up
|
||||
# by backing up this one path — together with SEAWEED_DATA below.
|
||||
PB_DATA=pb_data
|
||||
|
||||
# --- File storage: SeaweedFS -------------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# The credentials do double duty: seaweedfs-init writes them into the filer's
|
||||
# IAM store as the identity named "drivervault" *and* they are what PocketBase
|
||||
# authenticates with. There are no safe defaults, and the stack refuses to start
|
||||
# without them. Change them here and restart to rotate: the seed updates the
|
||||
# identity in place rather than adding a second one.
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
|
||||
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
|
||||
# by default; set an absolute host path for a bind mount, the same way PB_DATA
|
||||
# works above. Back it up alongside PB_DATA: from here on the attachments live
|
||||
# here, not in the database volume.
|
||||
#
|
||||
# The master, volume and filer containers all mount it at /data, which is the
|
||||
# layout `weed server -dir=/data` writes — so this file and
|
||||
# docker-compose.prod.seaweedfs.yml are interchangeable on the same volume, with
|
||||
# nothing to migrate either way.
|
||||
SEAWEED_DATA=seaweed_data
|
||||
# The SeaweedFS image, pinned so a redeploy months from now brings up the same
|
||||
# one. All five SeaweedFS containers run it.
|
||||
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||
# The S3 port is published on loopback only — the stack reaches the gateway over
|
||||
# the compose network, and this is for tools like aws-cli. Set
|
||||
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
|
||||
# SEAWEED_S3_BIND=127.0.0.1
|
||||
# SEAWEED_S3_PORT=8333
|
||||
#
|
||||
# The master, volume and filer publish no host port at all. The admin UI below
|
||||
# shows what they would: the volume server in particular serves file content by
|
||||
# id with no authentication, so it stays on the compose network. Reach the
|
||||
# others with `docker compose exec`.
|
||||
|
||||
# --- SeaweedFS admin UI ------------------------------------------------------
|
||||
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
|
||||
# Users, where further S3 identities are created and revoked. They land in the
|
||||
# filer's IAM store, the same one seeded above, and the gateway picks them up
|
||||
# without a restart.
|
||||
#
|
||||
# REQUIRED: weed disables authentication entirely when the password is empty,
|
||||
# and this panel can mint credentials for the bucket.
|
||||
SEAWEED_ADMIN_USER=admin
|
||||
SEAWEED_ADMIN_PASSWORD=
|
||||
# Optional view-only login.
|
||||
SEAWEED_ADMIN_READONLY_USER=
|
||||
SEAWEED_ADMIN_READONLY_PASSWORD=
|
||||
# Bound to localhost by default, like PB_BIND and API_BIND. On a remote host
|
||||
# that means unreachable — set 0.0.0.0 and put it behind the same reverse proxy
|
||||
# as the other panels.
|
||||
SEAWEED_ADMIN_BIND=127.0.0.1
|
||||
SEAWEED_ADMIN_PORT=23646
|
||||
# Its own small volume: session key and maintenance-task state, no object data.
|
||||
SEAWEED_ADMIN_DATA=seaweed_admin
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Copy to .env and fill in. Used by the root docker-compose.s3.yml.
|
||||
|
||||
# --- PocketBase superuser (also used by the API Server to authenticate) ------
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave these blank and the
|
||||
# schema is still created but no user is, leaving a stack you cannot log into.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# --- API Server -------------------------------------------------------------
|
||||
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
|
||||
# Native mobile apps are not subject to CORS.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
AUTH_USERS_COLLECTION=users
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. This dev stack serves plain
|
||||
# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the
|
||||
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||
# thing in the Anker app, then read the frames back out of the container log.
|
||||
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||
# seconds.
|
||||
ANKER_MQTT_FRAME_LOG=
|
||||
|
||||
# The charger can dial either door: the API Server port directly, or the Web
|
||||
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||
# is whichever host the panel itself was reached on, which is the Web App.
|
||||
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||
# whatever the client said it was.
|
||||
TRUST_FORWARDED_PROTO=false
|
||||
|
||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||
PB_PORT=8070
|
||||
API_PORT=8080
|
||||
WEB_PORT=8090
|
||||
|
||||
# --- Web App build -----------------------------------------------------------
|
||||
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
|
||||
VITE_API_BASE=
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the values below are re-applied on every restart and win. Set
|
||||
# them here to make a change permanent.
|
||||
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||
# the bundled pocketbase service; set it to reach one
|
||||
# outside this stack.
|
||||
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||
# a container-to-container call, so it must be reachable
|
||||
# from the API Server, not from your browser.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- File storage: external S3 -----------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
|
||||
# already exist. For a gateway on this Docker host use
|
||||
# http://host.docker.internal:8333 — the compose file adds the host entry that
|
||||
# makes that name resolve inside the containers.
|
||||
PB_S3_ENDPOINT=http://host.docker.internal:8333
|
||||
PB_S3_BUCKET=drivervault
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Copy to .env and fill in. Used by the root docker-compose.seaweedfs.yml.
|
||||
|
||||
# --- PocketBase superuser (also used by the API Server to authenticate) ------
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave these blank and the
|
||||
# schema is still created but no user is, leaving a stack you cannot log into.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# --- API Server -------------------------------------------------------------
|
||||
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
|
||||
# Native mobile apps are not subject to CORS.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
AUTH_USERS_COLLECTION=users
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. This dev stack serves plain
|
||||
# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the
|
||||
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||
# thing in the Anker app, then read the frames back out of the container log.
|
||||
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||
# seconds.
|
||||
ANKER_MQTT_FRAME_LOG=
|
||||
|
||||
# The charger can dial either door: the API Server port directly, or the Web
|
||||
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||
# is whichever host the panel itself was reached on, which is the Web App.
|
||||
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||
# whatever the client said it was.
|
||||
TRUST_FORWARDED_PROTO=false
|
||||
|
||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||
PB_PORT=8070
|
||||
API_PORT=8080
|
||||
WEB_PORT=8090
|
||||
|
||||
# --- Web App build -----------------------------------------------------------
|
||||
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
|
||||
VITE_API_BASE=
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the values below are re-applied on every restart and win. Set
|
||||
# them here to make a change permanent.
|
||||
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||
# the bundled pocketbase service; set it to reach one
|
||||
# outside this stack.
|
||||
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||
# a container-to-container call, so it must be reachable
|
||||
# from the API Server, not from your browser.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- File storage: SeaweedFS -------------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# The credentials do double duty: they configure the SeaweedFS gateway's single
|
||||
# identity *and* are what PocketBase authenticates with. There are no safe
|
||||
# defaults, and the stack refuses to start without them.
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# The gateway image, pinned so a rebuild months from now brings up the same one.
|
||||
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||
# Host port for the S3 API, so aws-cli and friends can reach it while developing.
|
||||
# SEAWEED_S3_PORT=8333
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Copy to .env and fill in. Used by docker-compose.seaweedfs.split.yml — the
|
||||
# same stack as .env.seaweedfs.example, with SeaweedFS running as separate
|
||||
# master / volume / filer / S3 containers plus the SeaweedFS admin UI.
|
||||
|
||||
# --- PocketBase superuser (also used by the API Server to authenticate) ------
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
PB_ADMIN_PASSWORD=change-me-long-password
|
||||
|
||||
# --- DriverVault super-admin (app login) -------------------------------------
|
||||
# The first application user, created by the API Server on boot with role
|
||||
# "superadmin" if no user with this email exists yet. Leave these blank and the
|
||||
# schema is still created but no user is, leaving a stack you cannot log into.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# --- API Server -------------------------------------------------------------
|
||||
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
|
||||
# Native mobile apps are not subject to CORS.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
AUTH_USERS_COLLECTION=users
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. This dev stack serves plain
|
||||
# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the
|
||||
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||
# thing in the Anker app, then read the frames back out of the container log.
|
||||
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||
# seconds.
|
||||
ANKER_MQTT_FRAME_LOG=
|
||||
|
||||
# The charger can dial either door: the API Server port directly, or the Web
|
||||
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||
# is whichever host the panel itself was reached on, which is the Web App.
|
||||
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||
# whatever the client said it was.
|
||||
TRUST_FORWARDED_PROTO=false
|
||||
|
||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||
PB_PORT=8070
|
||||
API_PORT=8080
|
||||
WEB_PORT=8090
|
||||
|
||||
# --- Web App build -----------------------------------------------------------
|
||||
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
|
||||
VITE_API_BASE=
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the values below are re-applied on every restart and win. Set
|
||||
# them here to make a change permanent.
|
||||
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||
# the bundled pocketbase service; set it to reach one
|
||||
# outside this stack.
|
||||
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||
# a container-to-container call, so it must be reachable
|
||||
# from the API Server, not from your browser.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- File storage: SeaweedFS -------------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# The credentials do double duty: seaweedfs-init writes them into the filer's
|
||||
# IAM store as the identity named "drivervault" *and* they are what PocketBase
|
||||
# authenticates with. There are no safe defaults, and the stack refuses to start
|
||||
# without them. Change them here and restart to rotate: the seed updates the
|
||||
# identity in place rather than adding a second one.
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# The SeaweedFS image, pinned so a rebuild months from now brings up the same
|
||||
# one. All five SeaweedFS containers run it.
|
||||
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||
|
||||
# --- SeaweedFS admin UI ------------------------------------------------------
|
||||
# http://localhost:23646 — cluster topology, volumes, buckets, and
|
||||
# Object Store → Users, where further S3 identities are created and revoked.
|
||||
# They land in the filer's IAM store, the same one seeded above, and the gateway
|
||||
# picks them up without a restart.
|
||||
#
|
||||
# REQUIRED: weed disables authentication entirely when the password is empty,
|
||||
# and this panel can mint credentials for the bucket.
|
||||
SEAWEED_ADMIN_USER=admin
|
||||
SEAWEED_ADMIN_PASSWORD=
|
||||
# SEAWEED_ADMIN_PORT=23646
|
||||
|
||||
# --- SeaweedFS host ports (optional; defaults shown) -------------------------
|
||||
# Published for aws-cli, `weed shell` and poking around while developing. The
|
||||
# stack itself reaches every one of these over the compose network.
|
||||
# Note SEAWEED_VOLUME_PORT: the volume server serves file content by id with NO
|
||||
# authentication, so do not carry this mapping over to a shared machine. It
|
||||
# lands on 8081 because API_PORT already has 8080.
|
||||
# SEAWEED_MASTER_PORT=9333
|
||||
# SEAWEED_VOLUME_PORT=8081
|
||||
# SEAWEED_FILER_PORT=8888
|
||||
# SEAWEED_S3_PORT=8333
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -1,24 +0,0 @@
|
||||
# Caddy in front of the stack: one hostname, TLS from Let's Encrypt, everything
|
||||
# behind it spoken to over the compose network in plain HTTP.
|
||||
#
|
||||
# Both doors are the same door here. Browsers get the Web App; chargers dial
|
||||
# /ocpp/{serial} on the same hostname, and the BFF carries that through to the
|
||||
# API Server. Caddy proxies WebSocket upgrades without being told to, so there
|
||||
# is nothing to configure for the charger case.
|
||||
#
|
||||
# DV_DOMAIN and DV_ACME_EMAIL come from .env via docker-compose.tls.yml.
|
||||
|
||||
{
|
||||
email {$DV_ACME_EMAIL}
|
||||
}
|
||||
|
||||
{$DV_DOMAIN} {
|
||||
encode zstd gzip
|
||||
|
||||
# X-Forwarded-Proto is set by Caddy to the scheme the client used. The API
|
||||
# Server reads it to decide a charger arrived over TLS, which is why the
|
||||
# web-app service is given TRUST_FORWARDED_PROTO=true — without that the BFF
|
||||
# would overwrite this header with its own plaintext hop and a charger would
|
||||
# be rejected under OCPP_REQUIRE_TLS.
|
||||
reverse_proxy web-app:8090
|
||||
}
|
||||
+110
-22
@@ -78,6 +78,102 @@ instead. PocketBase runs as root, so a root-owned host directory is fine.
|
||||
> `WEBAPP_URL` and `CORS_ALLOW_ORIGINS` in `.env` to change them permanently —
|
||||
> in this stack the compose environment wins over anything the panel writes.
|
||||
|
||||
## File storage (SeaweedFS / S3)
|
||||
|
||||
Uploaded files — document scans, service and refill receipts, workshop invoices,
|
||||
part photos — live inside `pb_data` by default, next to the database. Two further
|
||||
compose files put them in an S3 bucket instead, so the blobs and the database can
|
||||
be sized, backed up and moved independently. Nothing else changes: an attachment has
|
||||
always been fetched through the API Server (`GET /api/service-records/{id}/file`),
|
||||
never from a storage URL, so the Web App, the phone app and the Home Assistant
|
||||
plugin cannot tell the difference.
|
||||
|
||||
Each shape is one self-contained compose file — nothing to layer, nothing to
|
||||
remember — with an `.env` example of the same name:
|
||||
|
||||
| Shape | From the registry | From source |
|
||||
|---|---|---|
|
||||
| **Local storage** — the default, unchanged | `docker-compose.prod.yml` | `docker-compose.yml` |
|
||||
| **SeaweedFS in this stack** | `docker-compose.prod.seaweedfs.yml` | `docker-compose.seaweedfs.yml` |
|
||||
| **SeaweedFS, split into its roles** | `docker-compose.prod.seaweedfs.split.yml` | `docker-compose.seaweedfs.split.yml` |
|
||||
| **An S3 endpoint outside it** | `docker-compose.prod.s3.yml` | `docker-compose.s3.yml` |
|
||||
|
||||
So `docker-compose.prod.seaweedfs.yml` is configured from
|
||||
`.env.prod.seaweedfs.example`, `docker-compose.s3.yml` from `.env.s3.example`,
|
||||
and so on:
|
||||
|
||||
```sh
|
||||
cp .env.prod.seaweedfs.example .env # then edit it — PB_S3_* have no defaults
|
||||
docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||
docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||
```
|
||||
|
||||
Set `PB_S3_ACCESS_KEY` and `PB_S3_SECRET` first — both storage files refuse to
|
||||
start without them. The SeaweedFS ones add a `seaweedfs` container (master,
|
||||
volume, filer and S3 gateway in one process, on its own `seaweed_data` volume)
|
||||
plus a one-shot `seaweedfs-init` that creates the bucket, because PocketBase never
|
||||
issues a `CreateBucket` of its own. The external-S3 ones add no containers at
|
||||
all: set `PB_S3_ENDPOINT`, and create the bucket yourself.
|
||||
|
||||
### Split SeaweedFS
|
||||
|
||||
`weed server -s3` runs master, volume, filer and gateway as four goroutines in
|
||||
one process. The `.split.` files run them as four containers, plus a fifth: the
|
||||
SeaweedFS **admin UI** on port 23646, where the cluster can be inspected and —
|
||||
under *Object Store → Users* — further S3 identities minted and revoked. Split
|
||||
also gets you per-role restarts and upgrades, per-role Prometheus metrics, and
|
||||
room to add a second volume server later.
|
||||
|
||||
Identities work differently there, and it matters. SeaweedFS reads credentials
|
||||
from, in descending priority: an `-s3.config` file, the filer's IAM store, then
|
||||
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — and a higher source *replaces*
|
||||
a lower one rather than adding to it. The single-process files use the env vars,
|
||||
which is why nothing else may write identities there: the first user added in a
|
||||
panel would displace PocketBase's key. So in the split files `seaweedfs-init`
|
||||
seeds PocketBase's identity into the filer's store instead — the same store the
|
||||
admin UI writes — and the gateway runs with no config file at all. One source of
|
||||
truth, PocketBase's key visible in the panel beside every other, and new keys
|
||||
picked up without a restart. Rotating `PB_S3_SECRET` in `.env` and restarting
|
||||
updates that identity in place.
|
||||
|
||||
Set `SEAWEED_ADMIN_PASSWORD`: `weed admin` serves the panel with no
|
||||
authentication when it is empty, and a panel that can mint bucket credentials is
|
||||
the bucket. In the prod file it is bound to loopback like `PB_BIND` and
|
||||
`API_BIND`, so a remote host needs `SEAWEED_ADMIN_BIND=0.0.0.0` behind the same
|
||||
reverse proxy. That file publishes nothing for master, volume and filer — the
|
||||
volume server serves file content by id with no authentication of any kind, and
|
||||
the admin UI already shows what those ports would.
|
||||
|
||||
Switching between `docker-compose.seaweedfs.yml` and its `.split.` twin needs no
|
||||
migration: master, volume and filer share one `/data` mount, which is exactly
|
||||
the layout `weed server -dir=/data` writes.
|
||||
|
||||
On every boot the API Server's bootstrap writes PocketBase's *Files storage*
|
||||
settings from those variables, then asks PocketBase to prove it can reach the
|
||||
bucket. Watch for it in the log:
|
||||
|
||||
```
|
||||
[api] bootstrap: ✓ file storage → S3 (drivervault at http://seaweedfs:8333)
|
||||
[api] bootstrap: ✓ S3 storage reachable
|
||||
```
|
||||
|
||||
A boot that finds the settings already correct logs `• file storage already on S3`
|
||||
and writes nothing.
|
||||
|
||||
Two things to know before turning it on:
|
||||
|
||||
- **Existing files are not migrated.** PocketBase copies nothing when the setting
|
||||
flips, so attachments uploaded before the switch stop resolving. Copy
|
||||
`pb_data/storage/<collectionId>/<recordId>/<file>` into the bucket root, keeping
|
||||
that layout, *before* enabling it — or start from a stack with no attachments.
|
||||
- **Going back to the plain compose file is not an off switch.** It leaves
|
||||
PocketBase pointed at
|
||||
the bucket, deliberately: files already written there are reachable only while
|
||||
it is. Move them back and turn it off in PocketBase's own admin UI. For the same
|
||||
reason a rotation of `PB_S3_SECRET` alone is invisible to the bootstrap —
|
||||
PocketBase masks the stored secret on read — so change another `PB_S3_*` value
|
||||
alongside it, or set it in the admin UI.
|
||||
|
||||
## Charger control (OCPP)
|
||||
|
||||
Chargers in own/proxy mode dial in to `/ocpp/{serial}`, authenticating with a
|
||||
@@ -93,34 +189,26 @@ A plaintext `ws://` puts the control token on the wire in the clear, so
|
||||
|
||||
### With a public hostname and TLS
|
||||
|
||||
`docker-compose.tls.yml` adds Caddy in front of the stack: one hostname, a
|
||||
certificate issued on first boot, and everything behind it spoken to over the
|
||||
compose network. Browsers and chargers arrive at the same name.
|
||||
Nothing in this stack terminates TLS. Put your own reverse proxy in front of the
|
||||
Web App port, give it a certificate and a hostname, and set four things by hand:
|
||||
|
||||
```sh
|
||||
# in .env
|
||||
DV_DOMAIN=drivervault.example.com
|
||||
DV_ACME_EMAIL=you@example.com
|
||||
|
||||
docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
|
||||
OCPP_PUBLIC_URL=wss://drivervault.example.com
|
||||
OCPP_REQUIRE_TLS=true
|
||||
CORS_ALLOW_ORIGINS=https://drivervault.example.com
|
||||
TRUST_FORWARDED_PROTO=true
|
||||
```
|
||||
|
||||
The overlay sets the rest for you: `OCPP_PUBLIC_URL=wss://$DV_DOMAIN`,
|
||||
`OCPP_REQUIRE_TLS=true`, `CORS_ALLOW_ORIGINS=https://$DV_DOMAIN`, and
|
||||
`TRUST_FORWARDED_PROTO=true` on the Web App so the BFF passes Caddy's
|
||||
`X-Forwarded-Proto` to the API Server instead of overwriting it with its own
|
||||
plaintext hop. Point the charger's OCPP backend at the endpoint the panel then
|
||||
shows, with the control token as its authorization key.
|
||||
`TRUST_FORWARDED_PROTO` is the easy one to miss: without it the Web App's BFF
|
||||
overwrites the proxy's `X-Forwarded-Proto` with its own plaintext hop and every
|
||||
charger is rejected as insecure. Only set it when that proxy really is the only
|
||||
way in — otherwise a charger could claim `wss` over a plaintext connection.
|
||||
|
||||
Two things the overlay cannot arrange: `DV_DOMAIN` must resolve to the host from
|
||||
the internet with ports 80 and 443 reaching it (Caddy needs `:80` for the ACME
|
||||
challenge), and the charger must be able to resolve that name too — behind NAT
|
||||
that usually means hairpin NAT or a split-DNS entry pointing it at the LAN
|
||||
address.
|
||||
|
||||
Using a proxy you already run instead? Terminate TLS there, forward to the Web
|
||||
App port, and set the same four variables by hand — the `X-Forwarded-Proto` one
|
||||
included, or chargers will be rejected as insecure.
|
||||
Point the charger's OCPP backend at the endpoint the panel then shows, with the
|
||||
control token as its authorization key. The charger has to resolve that hostname
|
||||
too: behind NAT that usually means hairpin NAT, or a split-DNS entry pointing the
|
||||
name at the LAN address.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
name: drivervault
|
||||
|
||||
# Production DriverVault stack, with external S3 — pulls prebuilt images from
|
||||
# the registry instead of building from source. Self-contained: one file, no
|
||||
# overlays. Everything an operator needs to set lives in .env.
|
||||
#
|
||||
# 1. cp .env.prod.s3.example .env (then edit it — PB_S3_* especially)
|
||||
# 2. docker compose -f docker-compose.prod.s3.yml pull
|
||||
# 3. docker compose -f docker-compose.prod.s3.yml up -d
|
||||
#
|
||||
# This is docker-compose.prod.yml pointed at an S3 endpoint that already exists
|
||||
# somewhere else — its own host, another compose project, or any S3-compatible
|
||||
# service. PocketBase keeps its record files — document scans, service and
|
||||
# refill receipts, workshop invoices, part photos — in that bucket instead of on
|
||||
# the pb_data volume. The database and PocketBase's own backups stay on PB_DATA.
|
||||
# Clients cannot tell the difference: an attachment has always been fetched
|
||||
# through the API Server, never from a storage URL.
|
||||
#
|
||||
# The bucket must already exist, and nothing here runs the gateway. For a
|
||||
# SeaweedFS that comes up with the stack, use docker-compose.prod.seaweedfs.yml.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
#
|
||||
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||
#
|
||||
# On first boot:
|
||||
# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing).
|
||||
# • the API Server creates any missing collections, reconciles existing ones,
|
||||
# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*.
|
||||
# Both steps are idempotent, so restarts and upgrades are safe.
|
||||
|
||||
services:
|
||||
pocketbase:
|
||||
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
|
||||
container_name: drivervault-pocketbase
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||
# Harmless when the endpoint is somewhere else entirely.
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# The superuser is created/updated on boot (the API Server authenticates
|
||||
# with it). This is the only place the first superuser can be created — the
|
||||
# REST API cannot bootstrap it.
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
volumes:
|
||||
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
|
||||
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||
ports:
|
||||
# Bound to localhost by default — the admin UI (/_/) is reachable only on
|
||||
# the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network.
|
||||
- "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
api-server:
|
||||
image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}"
|
||||
container_name: drivervault-api
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||
# Harmless when the endpoint is somewhere else entirely.
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
API_ADDR: ":8080"
|
||||
# Reach PocketBase by its service name on the internal network. Override
|
||||
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||
# this stack — that is also how you make a retarget done from the panel
|
||||
# permanent, since the panel's change lasts only for the container's life.
|
||||
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||
# Probed by the panel status page. This is a server-to-server call inside
|
||||
# the compose network, so the default is the service name — plain
|
||||
# localhost:8090 would resolve to this container itself. Override
|
||||
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||
# permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||
# add collections or fields the server needs, and a stack that skips the
|
||||
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||
# app_settings, the collection holding the plugin settings, which it
|
||||
# creates on demand because it cannot serve the plugin panel without it.
|
||||
# Every other schema change still depends on this flag. Turn it off only
|
||||
# for a database you know already matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||
# reach the server over TLS. Behind a TLS-terminating reverse proxy, set
|
||||
# OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can
|
||||
# reach this port; only drop OCPP_REQUIRE_TLS on a trusted network.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||
# do something in the Anker app while a control read holds the connection
|
||||
# open, and read the frames back out of the log. Off by default: with it on
|
||||
# a charger under a live trigger writes a line every few seconds.
|
||||
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on PB_DATA. The bucket must already exist: nothing here
|
||||
# creates it.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
|
||||
ports:
|
||||
# Localhost-only by default (the Web App reaches it over the internal
|
||||
# network). Set API_BIND=0.0.0.0 to expose the API panel — and the
|
||||
# /ocpp/{serial} endpoint chargers dial into — on the network.
|
||||
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
|
||||
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||
# plugin settings included, lives in PocketBase under PB_DATA.
|
||||
healthcheck:
|
||||
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||
# depends_on gate below still works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
web-app:
|
||||
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
|
||||
container_name: drivervault-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||
# serving rather than merely started.
|
||||
api-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||
# told to dial — to the API Server over the internal network.
|
||||
API_BASE: "http://api-server:8080"
|
||||
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||
ports:
|
||||
# The public front door. Bound on all interfaces so browsers can reach it.
|
||||
- "${WEB_PORT:-8090}:8090"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
@@ -0,0 +1,394 @@
|
||||
name: drivervault
|
||||
|
||||
# Production DriverVault stack, with SeaweedFS split into its four roles —
|
||||
# pulls prebuilt images from the registry instead of building from source.
|
||||
# Self-contained: one file, no overlays. Everything an operator needs to set
|
||||
# lives in .env.
|
||||
#
|
||||
# 1. cp .env.prod.seaweedfs.split.example .env (then edit it)
|
||||
# 2. docker compose -f docker-compose.prod.seaweedfs.split.yml pull
|
||||
# 3. docker compose -f docker-compose.prod.seaweedfs.split.yml up -d
|
||||
#
|
||||
# This is docker-compose.prod.seaweedfs.yml with the storage layer taken apart.
|
||||
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
|
||||
# process; here each is its own container, plus the SeaweedFS admin UI. What
|
||||
# that buys:
|
||||
#
|
||||
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
|
||||
# where S3 identities are created and revoked without touching a file;
|
||||
# • per-role restart, upgrade and Prometheus metrics;
|
||||
# • room to add a second volume server later, on this host or another.
|
||||
#
|
||||
# What it costs: five containers instead of one, five healthchecks to keep the
|
||||
# boot order honest, and one more port worth binding carefully. If none of the
|
||||
# above is wanted, use docker-compose.prod.seaweedfs.yml — the S3 behaviour is
|
||||
# identical.
|
||||
#
|
||||
# The on-disk layout is deliberately the same as the single-process file's:
|
||||
# master, volume and filer share one /data mount, exactly as `weed server -dir`
|
||||
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
|
||||
# filename overlap). So the two files are interchangeable on the same SEAWEED_DATA,
|
||||
# with no migration either way. A *second* volume server would need its own.
|
||||
#
|
||||
# Only the S3 gateway and the admin UI publish a host port. Master, volume and
|
||||
# filer are reachable over the compose network, through the admin UI, or with
|
||||
# `docker compose exec` — the volume server in particular serves file content by
|
||||
# id with no authentication at all, so it has no business on a public interface.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
#
|
||||
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||
#
|
||||
# On first boot:
|
||||
# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing).
|
||||
# • the API Server creates any missing collections, reconciles existing ones,
|
||||
# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*.
|
||||
# Both steps are idempotent, so restarts and upgrades are safe.
|
||||
|
||||
services:
|
||||
# --- SeaweedFS: master -----------------------------------------------------
|
||||
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
|
||||
# the other roles are told to reach it by, so it must be the service name and
|
||||
# not the container IP the process would otherwise detect.
|
||||
seaweedfs-master:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-master
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
|
||||
-volumeSizeLimitMB=1024 -metricsPort=9324
|
||||
volumes:
|
||||
# Named volume by default; set SEAWEED_DATA to a host path in .env for a
|
||||
# bind mount, exactly as PB_DATA works. Back it up alongside PB_DATA —
|
||||
# from here on the attachments live here, not in the database volume.
|
||||
- "${SEAWEED_DATA:-seaweed_data}:/data"
|
||||
# No published port: the master UI is one of the pages the admin UI serves.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: volume server ---------------------------------------------
|
||||
# Where the bytes actually land. -max=0 lets it size itself from free disk
|
||||
# rather than the default cap of 8 volumes.
|
||||
seaweedfs-volume:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-volume
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
|
||||
-port=8080 -dir=/data -max=0 -metricsPort=9325
|
||||
depends_on:
|
||||
seaweedfs-master:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- "${SEAWEED_DATA:-seaweed_data}:/data"
|
||||
# No published port, and this one is not an oversight: 8080 serves file
|
||||
# content by file id with NO authentication — the S3 credentials do not
|
||||
# apply to it. Publishing it would publish every attachment in the stack.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: filer ------------------------------------------------------
|
||||
# Gives the flat volume store a directory tree — buckets, object keys — and
|
||||
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
|
||||
# embedded leveldb goes; without it that would be the container's working
|
||||
# directory, and the identities would not survive a recreate.
|
||||
seaweedfs-filer:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-filer
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
|
||||
-port=8888 -defaultStoreDir=/data -metricsPort=9326
|
||||
depends_on:
|
||||
seaweedfs-volume:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- "${SEAWEED_DATA:-seaweed_data}:/data"
|
||||
# No published port. The filer's gRPC side (8888 + 10000) carries the IAM
|
||||
# service that mints S3 credentials; keep both ends of it on the compose
|
||||
# network.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: bucket and identity seed ----------------------------------
|
||||
# Runs once and exits, before the gateway starts. Two jobs:
|
||||
#
|
||||
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
|
||||
# 2. write PocketBase's S3 identity into the filer's IAM store.
|
||||
#
|
||||
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
|
||||
# docker-compose.prod.seaweedfs.yml does. Those env vars are the *lowest*
|
||||
# priority credential source in SeaweedFS: they are read only while the filer's
|
||||
# store is empty, so the first identity added in the admin UI would silently
|
||||
# displace them and lock PocketBase out. Seeding the store the admin UI itself
|
||||
# writes leaves one source of truth, and the key PocketBase uses appears under
|
||||
# Object Store → Users like any other.
|
||||
#
|
||||
# Both commands update in place, so every later boot re-applies the values from
|
||||
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
|
||||
# reaches the gateway.
|
||||
#
|
||||
# The closing grep is the gate: an empty IAM store means the gateway would come
|
||||
# up in its allow-anyone default, so this fails loudly instead and the gateway
|
||||
# below never starts. No `|| true` here, deliberately.
|
||||
seaweedfs-init:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-init
|
||||
restart: "no"
|
||||
depends_on:
|
||||
seaweedfs-filer:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Passed as env and expanded by the shell inside the container, so the
|
||||
# secret stays out of the container's argv.
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -e
|
||||
printf '%s\n' \
|
||||
"s3.bucket.create -name $$PB_S3_BUCKET" \
|
||||
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
|
||||
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
|
||||
echo "s3.configure" \
|
||||
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
|
||||
| grep -q "$$PB_S3_ACCESS_KEY"
|
||||
|
||||
# --- SeaweedFS: S3 gateway -------------------------------------------------
|
||||
# The endpoint PocketBase talks to. No -config file: with only -filer given,
|
||||
# credentials come from the filer's IAM store, which is what lets the admin UI
|
||||
# add and revoke identities without a restart. A config file would take
|
||||
# priority over that store and make the admin UI's users inert.
|
||||
seaweedfs-s3:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-s3
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
|
||||
-metricsPort=9327
|
||||
depends_on:
|
||||
seaweedfs-filer:
|
||||
condition: service_healthy
|
||||
# Never serve before an identity exists — see seaweedfs-init above.
|
||||
seaweedfs-init:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
# Loopback only: the stack reaches the gateway over the compose network,
|
||||
# so this is here for `aws s3 ls --endpoint-url http://127.0.0.1:8333` and
|
||||
# nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to expose it, and mean it.
|
||||
- "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8333/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: admin UI ---------------------------------------------------
|
||||
# Cluster topology, volumes, buckets, maintenance tasks, and Object Store →
|
||||
# Users, where S3 access keys are minted and revoked. It finds the filer
|
||||
# through the master, so -master is all it needs.
|
||||
#
|
||||
# Bound to loopback by default, like the PocketBase and API panels: on a remote
|
||||
# host that means unreachable, so set SEAWEED_ADMIN_BIND=0.0.0.0 the same way
|
||||
# PB_BIND and API_BIND get set — and put it behind the same reverse proxy.
|
||||
#
|
||||
# An unauthenticated panel that can mint credentials for the bucket *is* the
|
||||
# bucket, so the password is required rather than defaulted — weed leaves auth
|
||||
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
|
||||
# flag, which keeps it off the process command line. -dataDir persists the
|
||||
# session key and the maintenance-task settings.
|
||||
seaweedfs-admin:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-admin
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
|
||||
-metricsPort=9328
|
||||
depends_on:
|
||||
seaweedfs-master:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
|
||||
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
|
||||
# Optional view-only login. weed ignores it unless the admin password
|
||||
# above is set, which it is.
|
||||
WEED_ADMIN_READONLY_USER: "${SEAWEED_ADMIN_READONLY_USER:-}"
|
||||
WEED_ADMIN_READONLY_PASSWORD: "${SEAWEED_ADMIN_READONLY_PASSWORD:-}"
|
||||
volumes:
|
||||
# Its own small volume: session key and maintenance state, no object data.
|
||||
- "${SEAWEED_ADMIN_DATA:-seaweed_admin}:/data"
|
||||
ports:
|
||||
- "${SEAWEED_ADMIN_BIND:-127.0.0.1}:${SEAWEED_ADMIN_PORT:-23646}:23646"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
pocketbase:
|
||||
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
|
||||
container_name: drivervault-pocketbase
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# PocketBase is the process that reads and writes the objects, so the
|
||||
# gateway has to be serving before it is asked to store anything.
|
||||
seaweedfs-s3:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# The superuser is created/updated on boot (the API Server authenticates
|
||||
# with it). This is the only place the first superuser can be created — the
|
||||
# REST API cannot bootstrap it.
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
volumes:
|
||||
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
|
||||
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||
ports:
|
||||
# Bound to localhost by default — the admin UI (/_/) is reachable only on
|
||||
# the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network.
|
||||
- "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
api-server:
|
||||
image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}"
|
||||
container_name: drivervault-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
# The bucket must exist before the bootstrap points PocketBase at it.
|
||||
seaweedfs-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
API_ADDR: ":8080"
|
||||
# Reach PocketBase by its service name on the internal network. Override
|
||||
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||
# this stack — that is also how you make a retarget done from the panel
|
||||
# permanent, since the panel's change lasts only for the container's life.
|
||||
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||
# Probed by the panel status page. This is a server-to-server call inside
|
||||
# the compose network, so the default is the service name — plain
|
||||
# localhost:8090 would resolve to this container itself. Override
|
||||
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||
# permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||
# add collections or fields the server needs, and a stack that skips the
|
||||
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||
# app_settings, the collection holding the plugin settings, which it
|
||||
# creates on demand because it cannot serve the plugin panel without it.
|
||||
# Every other schema change still depends on this flag. Turn it off only
|
||||
# for a database you know already matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||
# reach the server over TLS. Behind a TLS-terminating reverse proxy, set
|
||||
# OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can
|
||||
# reach this port; only drop OCPP_REQUIRE_TLS on a trusted network.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||
# do something in the Anker app while a control read holds the connection
|
||||
# open, and read the frames back out of the log. Off by default: with it on
|
||||
# a charger under a live trigger writes a line every few seconds.
|
||||
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on PB_DATA.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
# The gateway's service name: a server-to-server call inside the compose
|
||||
# network.
|
||||
PB_S3_ENDPOINT: "http://seaweedfs-s3:8333"
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||
PB_S3_FORCE_PATH_STYLE: "true"
|
||||
ports:
|
||||
# Localhost-only by default (the Web App reaches it over the internal
|
||||
# network). Set API_BIND=0.0.0.0 to expose the API panel — and the
|
||||
# /ocpp/{serial} endpoint chargers dial into — on the network.
|
||||
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
|
||||
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||
# plugin settings included, lives in PocketBase under PB_DATA.
|
||||
healthcheck:
|
||||
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||
# depends_on gate below still works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
web-app:
|
||||
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
|
||||
container_name: drivervault-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||
# serving rather than merely started.
|
||||
api-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||
# told to dial — to the API Server over the internal network.
|
||||
API_BASE: "http://api-server:8080"
|
||||
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||
ports:
|
||||
# The public front door. Bound on all interfaces so browsers can reach it.
|
||||
- "${WEB_PORT:-8090}:8090"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
# Shared by master, volume and filer — the same layout `weed server -dir`
|
||||
# writes, so this file and docker-compose.prod.seaweedfs.yml can swap places
|
||||
# on it.
|
||||
seaweed_data:
|
||||
# The admin UI's own session key and maintenance-task state. Small, and no
|
||||
# part of the object store.
|
||||
seaweed_admin:
|
||||
@@ -0,0 +1,221 @@
|
||||
name: drivervault
|
||||
|
||||
# Production DriverVault stack, with SeaweedFS — pulls prebuilt images from the
|
||||
# registry instead of building from source. Self-contained: one file, no
|
||||
# overlays. Everything an operator needs to set lives in .env.
|
||||
#
|
||||
# 1. cp .env.prod.seaweedfs.example .env (then edit it)
|
||||
# 2. docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||
# 3. docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||
#
|
||||
# This is docker-compose.prod.yml plus an S3 object store: PocketBase keeps its
|
||||
# record files — document scans, service and refill receipts, workshop invoices,
|
||||
# part photos — in a SeaweedFS bucket instead of on the pb_data volume next to
|
||||
# the database. The database and PocketBase's own backups stay on PB_DATA.
|
||||
# Clients cannot tell the difference: an attachment has always been fetched
|
||||
# through the API Server, never from a storage URL.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
#
|
||||
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||
#
|
||||
# On first boot:
|
||||
# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing).
|
||||
# • the API Server creates any missing collections, reconciles existing ones,
|
||||
# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*.
|
||||
# Both steps are idempotent, so restarts and upgrades are safe.
|
||||
|
||||
services:
|
||||
seaweedfs:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs
|
||||
restart: unless-stopped
|
||||
# One process, four roles: master, volume, filer and the S3 gateway. -dir is
|
||||
# the only state it keeps.
|
||||
command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024
|
||||
environment:
|
||||
# SeaweedFS falls back to these when started without an -s3.config file,
|
||||
# and configuring one identity is what takes the S3 gateway out of its
|
||||
# default allow-anyone mode. The same credentials PocketBase authenticates
|
||||
# with below — one pair to set, in .env.
|
||||
AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||
AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||
volumes:
|
||||
# Named volume by default; set SEAWEED_DATA to a host path in .env for a
|
||||
# bind mount, exactly as PB_DATA works. Back it up alongside PB_DATA —
|
||||
# from here on the attachments live here, not in the database volume.
|
||||
- "${SEAWEED_DATA:-seaweed_data}:/data"
|
||||
ports:
|
||||
# Loopback only: the stack reaches the gateway over the compose network,
|
||||
# so this is here for `aws s3 ls --endpoint-url http://127.0.0.1:8333` and
|
||||
# nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to expose it, and mean it.
|
||||
- "${SEAWEED_S3_BIND:-127.0.0.1}:${SEAWEED_S3_PORT:-8333}:8333"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
seaweedfs-init:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-init
|
||||
# Runs once and exits. PocketBase never issues a CreateBucket of its own and
|
||||
# SeaweedFS will not conjure one on first upload, so something has to.
|
||||
# Creating a bucket that already exists is a no-op, so every later boot
|
||||
# passes straight through.
|
||||
restart: "no"
|
||||
depends_on:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
# `|| true` so a restart is never blocked by the shell's exit status: this
|
||||
# step is best-effort, and a gateway that is genuinely unreachable is
|
||||
# reported by the API Server's own S3 check at boot, with the reason.
|
||||
command:
|
||||
- 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true'
|
||||
|
||||
pocketbase:
|
||||
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
|
||||
container_name: drivervault-pocketbase
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# PocketBase is the process that reads and writes the objects, so the
|
||||
# gateway has to be serving before it is asked to store anything.
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# The superuser is created/updated on boot (the API Server authenticates
|
||||
# with it). This is the only place the first superuser can be created — the
|
||||
# REST API cannot bootstrap it.
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
volumes:
|
||||
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
|
||||
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||
ports:
|
||||
# Bound to localhost by default — the admin UI (/_/) is reachable only on
|
||||
# the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network.
|
||||
- "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
api-server:
|
||||
image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}"
|
||||
container_name: drivervault-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
# The bucket must exist before the bootstrap points PocketBase at it.
|
||||
seaweedfs-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
API_ADDR: ":8080"
|
||||
# Reach PocketBase by its service name on the internal network. Override
|
||||
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||
# this stack — that is also how you make a retarget done from the panel
|
||||
# permanent, since the panel's change lasts only for the container's life.
|
||||
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||
# Probed by the panel status page. This is a server-to-server call inside
|
||||
# the compose network, so the default is the service name — plain
|
||||
# localhost:8090 would resolve to this container itself. Override
|
||||
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||
# permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||
# add collections or fields the server needs, and a stack that skips the
|
||||
# bootstrap never gets them. The API Server self-heals exactly one thing —
|
||||
# app_settings, the collection holding the plugin settings, which it
|
||||
# creates on demand because it cannot serve the plugin panel without it.
|
||||
# Every other schema change still depends on this flag. Turn it off only
|
||||
# for a database you know already matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||
# reach the server over TLS. Behind a TLS-terminating reverse proxy, set
|
||||
# OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can
|
||||
# reach this port; only drop OCPP_REQUIRE_TLS on a trusted network.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||
# do something in the Anker app while a control read holds the connection
|
||||
# open, and read the frames back out of the log. Off by default: with it on
|
||||
# a charger under a live trigger writes a line every few seconds.
|
||||
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on PB_DATA.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
# The service name: a server-to-server call inside the compose network.
|
||||
PB_S3_ENDPOINT: "http://seaweedfs:8333"
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||
PB_S3_FORCE_PATH_STYLE: "true"
|
||||
ports:
|
||||
# Localhost-only by default (the Web App reaches it over the internal
|
||||
# network). Set API_BIND=0.0.0.0 to expose the API panel — and the
|
||||
# /ocpp/{serial} endpoint chargers dial into — on the network.
|
||||
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
|
||||
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||
# plugin settings included, lives in PocketBase under PB_DATA.
|
||||
healthcheck:
|
||||
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||
# depends_on gate below still works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
web-app:
|
||||
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
|
||||
container_name: drivervault-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||
# serving rather than merely started.
|
||||
api-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||
# told to dial — to the API Server over the internal network.
|
||||
API_BASE: "http://api-server:8080"
|
||||
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||
ports:
|
||||
# The public front door. Bound on all interfaces so browsers can reach it.
|
||||
- "${WEB_PORT:-8090}:8090"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
seaweed_data:
|
||||
@@ -0,0 +1,174 @@
|
||||
name: drivervault
|
||||
|
||||
# Full DriverVault stack, with external S3: PocketBase (database) + API Server +
|
||||
# Web App, built from source, storing files in an S3 endpoint that already
|
||||
# exists somewhere else. Self-contained — one file, nothing to layer.
|
||||
#
|
||||
# cp .env.s3.example .env (then edit it — PB_S3_* especially)
|
||||
# docker compose -f docker-compose.s3.yml up -d --build
|
||||
#
|
||||
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||
#
|
||||
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
|
||||
# document scans, service and refill receipts, workshop invoices, part photos —
|
||||
# in that bucket instead of on the pb_data volume next to the database. The
|
||||
# database and PocketBase's own backups stay on pb_data. Clients cannot tell the
|
||||
# difference: an attachment has always been fetched through the API Server,
|
||||
# never from a storage URL.
|
||||
#
|
||||
# The bucket must already exist, and nothing here runs the gateway. For a
|
||||
# SeaweedFS that comes up with the stack, use docker-compose.seaweedfs.yml.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
|
||||
services:
|
||||
pocketbase:
|
||||
build:
|
||||
context: ./pocketbase
|
||||
image: drivervault-pocketbase
|
||||
container_name: drivervault-pocketbase
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||
# Harmless when the endpoint is somewhere else entirely.
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# Superuser is created/updated on boot so the API Server can authenticate.
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
volumes:
|
||||
- pb_data:/pb/pb_data
|
||||
ports:
|
||||
# Admin UI / API exposed on the host for management (http://host:8070/_/).
|
||||
- "${PB_PORT:-8070}:8070"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
api-server:
|
||||
build:
|
||||
context: ../API Server
|
||||
image: drivervault-api
|
||||
container_name: drivervault-api
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||
# Harmless when the endpoint is somewhere else entirely.
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
API_ADDR: ":8080"
|
||||
# Reach PocketBase by its service name on the internal network. Override
|
||||
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||
# this stack — that is also how you make a retarget done from the panel
|
||||
# permanent, since the panel's change lasts only for the container's life.
|
||||
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||
# Probed by the panel status page. This is a server-to-server call inside
|
||||
# the compose network, so the default is the service name — plain
|
||||
# localhost:8090 would resolve to this container itself. Override
|
||||
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||
# permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||
# Same-origin requests go through the Web App BFF, so CORS is only needed
|
||||
# if the browser ever calls the API Server directly. Default to the web origin.
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||
# Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars
|
||||
# the collections are still created but no app user is, leaving a stack
|
||||
# you cannot log into.
|
||||
#
|
||||
# Leave the bootstrap ON: a release can add collections or fields the
|
||||
# server needs, and a stack that skips it never gets them. The API Server
|
||||
# self-heals exactly one thing — app_settings, the collection holding the
|
||||
# plugin settings, which it creates on demand because it cannot serve the
|
||||
# plugin panel without it. Every other schema change still depends on this
|
||||
# flag. Turn it off only for a database you know matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||
# connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is
|
||||
# terminated in front of this stack or for local dev on a trusted network.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||
# do something in the Anker app while a control read holds the connection
|
||||
# open, and read the frames back out of the log. Off by default: with it on
|
||||
# a charger under a live trigger writes a line every few seconds.
|
||||
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on pb_data. The bucket must already exist: nothing here
|
||||
# creates it.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
|
||||
ports:
|
||||
# Optional direct access to the API Server (and its panel at /); the Web
|
||||
# App reaches it over the internal network, not this host port. Chargers
|
||||
# dialling /ocpp/{serial} also arrive here.
|
||||
- "${API_PORT:-8080}:8080"
|
||||
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||
# plugin settings included, lives in PocketBase under pb_data.
|
||||
healthcheck:
|
||||
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||
# depends_on gate below still works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
web-app:
|
||||
build:
|
||||
context: ../Web App
|
||||
args:
|
||||
# Empty -> bundle uses same-origin "/api", which the BFF proxies below.
|
||||
VITE_API_BASE: "${VITE_API_BASE:-}"
|
||||
image: drivervault-web
|
||||
container_name: drivervault-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||
# serving rather than merely started.
|
||||
api-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||
# told to dial — to the API Server over the internal network.
|
||||
API_BASE: "http://api-server:8080"
|
||||
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||
ports:
|
||||
- "${WEB_PORT:-8090}:8090"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
@@ -0,0 +1,379 @@
|
||||
name: drivervault
|
||||
|
||||
# Full DriverVault stack, with SeaweedFS split into its four roles: PocketBase
|
||||
# (database) + API Server + Web App, built from source, plus master, volume,
|
||||
# filer, S3 gateway and the SeaweedFS admin UI as separate containers.
|
||||
# Self-contained — one file, nothing to layer.
|
||||
#
|
||||
# cp .env.seaweedfs.split.example .env (then edit it)
|
||||
# docker compose -f docker-compose.seaweedfs.split.yml up -d --build
|
||||
#
|
||||
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||
#
|
||||
# This is docker-compose.seaweedfs.yml with the storage layer taken apart.
|
||||
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
|
||||
# process; here each is its own container. What that buys:
|
||||
#
|
||||
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
|
||||
# where S3 identities are created and revoked without touching a file;
|
||||
# • per-role restart, upgrade and Prometheus metrics;
|
||||
# • room to add a second volume server later, on this host or another.
|
||||
#
|
||||
# What it costs: five containers instead of one, and five healthchecks to keep
|
||||
# the boot order honest. If none of the above is wanted, use
|
||||
# docker-compose.seaweedfs.yml — the S3 behaviour is identical.
|
||||
#
|
||||
# The on-disk layout is deliberately the same as the single-process file's:
|
||||
# master, volume and filer share one /data mount, exactly as `weed server -dir`
|
||||
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
|
||||
# filename overlap). So the two files are interchangeable on the same volume,
|
||||
# with no migration either way. A *second* volume server would need its own.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
|
||||
services:
|
||||
# --- SeaweedFS: master -----------------------------------------------------
|
||||
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
|
||||
# the other roles are told to reach it by, so it must be the service name and
|
||||
# not the container IP the process would otherwise detect.
|
||||
seaweedfs-master:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-master
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
|
||||
-volumeSizeLimitMB=1024 -metricsPort=9324
|
||||
volumes:
|
||||
- seaweed_data:/data
|
||||
ports:
|
||||
# Master UI / API. Useful while developing; the admin UI below covers the
|
||||
# same ground with a nicer face.
|
||||
- "${SEAWEED_MASTER_PORT:-9333}:9333"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: volume server ---------------------------------------------
|
||||
# Where the bytes actually land. -max=0 lets it size itself from free disk
|
||||
# rather than the default cap of 8 volumes.
|
||||
seaweedfs-volume:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-volume
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
|
||||
-port=8080 -dir=/data -max=0 -metricsPort=9325
|
||||
depends_on:
|
||||
seaweedfs-master:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- seaweed_data:/data
|
||||
ports:
|
||||
# This port serves file content by file id with NO authentication — the S3
|
||||
# credentials do not apply to it. Publish it only where you would be
|
||||
# willing to publish the bucket itself. Mapped to 8081 on the host because
|
||||
# 8080 there is the API Server.
|
||||
- "${SEAWEED_VOLUME_PORT:-8081}:8080"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: filer ------------------------------------------------------
|
||||
# Gives the flat volume store a directory tree — buckets, object keys — and
|
||||
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
|
||||
# embedded leveldb goes; without it that would be the container's working
|
||||
# directory, and the identities would not survive a recreate.
|
||||
seaweedfs-filer:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-filer
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
|
||||
-port=8888 -defaultStoreDir=/data -metricsPort=9326
|
||||
depends_on:
|
||||
seaweedfs-volume:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- seaweed_data:/data
|
||||
ports:
|
||||
- "${SEAWEED_FILER_PORT:-8888}:8888"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: bucket and identity seed ----------------------------------
|
||||
# Runs once and exits, before the gateway starts. Two jobs:
|
||||
#
|
||||
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
|
||||
# 2. write PocketBase's S3 identity into the filer's IAM store.
|
||||
#
|
||||
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
|
||||
# docker-compose.seaweedfs.yml does. Those env vars are the *lowest* priority
|
||||
# credential source in SeaweedFS: they are read only while the filer's store is
|
||||
# empty, so the first identity added in the admin UI would silently displace
|
||||
# them and lock PocketBase out. Seeding the store the admin UI itself writes
|
||||
# leaves one source of truth, and the key PocketBase uses appears under
|
||||
# Object Store → Users like any other.
|
||||
#
|
||||
# Both commands update in place, so every later boot re-applies the values from
|
||||
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
|
||||
# reaches the gateway.
|
||||
#
|
||||
# The closing grep is the gate: an empty IAM store means the gateway would come
|
||||
# up in its allow-anyone default, so this fails loudly instead and the gateway
|
||||
# below never starts. No `|| true` here, deliberately.
|
||||
seaweedfs-init:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-init
|
||||
restart: "no"
|
||||
depends_on:
|
||||
seaweedfs-filer:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Passed as env and expanded by the shell inside the container, so the
|
||||
# secret stays out of the container's argv.
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -e
|
||||
printf '%s\n' \
|
||||
"s3.bucket.create -name $$PB_S3_BUCKET" \
|
||||
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
|
||||
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
|
||||
echo "s3.configure" \
|
||||
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
|
||||
| grep -q "$$PB_S3_ACCESS_KEY"
|
||||
|
||||
# --- SeaweedFS: S3 gateway -------------------------------------------------
|
||||
# The endpoint PocketBase talks to. No -config file: with only -filer given,
|
||||
# credentials come from the filer's IAM store, which is what lets the admin UI
|
||||
# add and revoke identities without a restart. A config file would take
|
||||
# priority over that store and make the admin UI's users inert.
|
||||
seaweedfs-s3:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-s3
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
|
||||
-metricsPort=9327
|
||||
depends_on:
|
||||
seaweedfs-filer:
|
||||
condition: service_healthy
|
||||
# Never serve before an identity exists — see seaweedfs-init above.
|
||||
seaweedfs-init:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
# The stack reaches the gateway over the compose network; this is here so
|
||||
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
|
||||
- "${SEAWEED_S3_PORT:-8333}:8333"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8333/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
# --- SeaweedFS: admin UI ---------------------------------------------------
|
||||
# http://localhost:23646 — cluster topology, volumes, buckets, maintenance
|
||||
# tasks, and Object Store → Users, where S3 access keys are minted and revoked.
|
||||
# It finds the filer through the master, so -master is all it needs.
|
||||
#
|
||||
# An unauthenticated panel that can mint credentials for the bucket *is* the
|
||||
# bucket, so the password is required rather than defaulted — weed leaves auth
|
||||
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
|
||||
# flag, which keeps it off the process command line. -dataDir persists the
|
||||
# session key and the maintenance-task settings.
|
||||
seaweedfs-admin:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-admin
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
|
||||
-metricsPort=9328
|
||||
depends_on:
|
||||
seaweedfs-master:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
|
||||
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
|
||||
volumes:
|
||||
- seaweed_admin:/data
|
||||
ports:
|
||||
- "${SEAWEED_ADMIN_PORT:-23646}:23646"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
pocketbase:
|
||||
build:
|
||||
context: ./pocketbase
|
||||
image: drivervault-pocketbase
|
||||
container_name: drivervault-pocketbase
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# PocketBase is the process that reads and writes the objects, so the
|
||||
# gateway has to be serving before it is asked to store anything.
|
||||
seaweedfs-s3:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Superuser is created/updated on boot so the API Server can authenticate.
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
volumes:
|
||||
- pb_data:/pb/pb_data
|
||||
ports:
|
||||
# Admin UI / API exposed on the host for management (http://host:8070/_/).
|
||||
- "${PB_PORT:-8070}:8070"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
api-server:
|
||||
build:
|
||||
context: ../API Server
|
||||
image: drivervault-api
|
||||
container_name: drivervault-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
# The bucket must exist before the bootstrap points PocketBase at it.
|
||||
seaweedfs-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
API_ADDR: ":8080"
|
||||
# Reach PocketBase by its service name on the internal network. Override
|
||||
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||
# this stack — that is also how you make a retarget done from the panel
|
||||
# permanent, since the panel's change lasts only for the container's life.
|
||||
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||
# Probed by the panel status page. This is a server-to-server call inside
|
||||
# the compose network, so the default is the service name — plain
|
||||
# localhost:8090 would resolve to this container itself. Override
|
||||
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||
# permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||
# Same-origin requests go through the Web App BFF, so CORS is only needed
|
||||
# if the browser ever calls the API Server directly. Default to the web origin.
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||
# Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars
|
||||
# the collections are still created but no app user is, leaving a stack
|
||||
# you cannot log into.
|
||||
#
|
||||
# Leave the bootstrap ON: a release can add collections or fields the
|
||||
# server needs, and a stack that skips it never gets them. The API Server
|
||||
# self-heals exactly one thing — app_settings, the collection holding the
|
||||
# plugin settings, which it creates on demand because it cannot serve the
|
||||
# plugin panel without it. Every other schema change still depends on this
|
||||
# flag. Turn it off only for a database you know matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||
# connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is
|
||||
# terminated in front of this stack or for local dev on a trusted network.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||
# do something in the Anker app while a control read holds the connection
|
||||
# open, and read the frames back out of the log. Off by default: with it on
|
||||
# a charger under a live trigger writes a line every few seconds.
|
||||
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on pb_data.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
# The gateway's service name: a server-to-server call inside the compose
|
||||
# network.
|
||||
PB_S3_ENDPOINT: "http://seaweedfs-s3:8333"
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||
PB_S3_FORCE_PATH_STYLE: "true"
|
||||
ports:
|
||||
# Optional direct access to the API Server (and its panel at /); the Web
|
||||
# App reaches it over the internal network, not this host port. Chargers
|
||||
# dialling /ocpp/{serial} also arrive here.
|
||||
- "${API_PORT:-8080}:8080"
|
||||
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||
# plugin settings included, lives in PocketBase under pb_data.
|
||||
healthcheck:
|
||||
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||
# depends_on gate below still works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
web-app:
|
||||
build:
|
||||
context: ../Web App
|
||||
args:
|
||||
# Empty -> bundle uses same-origin "/api", which the BFF proxies below.
|
||||
VITE_API_BASE: "${VITE_API_BASE:-}"
|
||||
image: drivervault-web
|
||||
container_name: drivervault-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||
# serving rather than merely started.
|
||||
api-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||
# told to dial — to the API Server over the internal network.
|
||||
API_BASE: "http://api-server:8080"
|
||||
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||
ports:
|
||||
- "${WEB_PORT:-8090}:8090"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
# Shared by master, volume and filer — the same layout `weed server -dir`
|
||||
# writes, so this file and docker-compose.seaweedfs.yml can swap places on it.
|
||||
seaweed_data:
|
||||
# The admin UI's own session key and maintenance-task state. Small, and no
|
||||
# part of the object store.
|
||||
seaweed_admin:
|
||||
@@ -0,0 +1,219 @@
|
||||
name: drivervault
|
||||
|
||||
# Full DriverVault stack, with SeaweedFS: PocketBase (database) + API Server +
|
||||
# Web App, built from source, plus an S3 object store. Self-contained — one
|
||||
# file, nothing to layer.
|
||||
#
|
||||
# cp .env.seaweedfs.example .env (then edit it)
|
||||
# docker compose -f docker-compose.seaweedfs.yml up -d --build
|
||||
#
|
||||
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||
#
|
||||
# This is docker-compose.yml plus storage: PocketBase keeps its record files —
|
||||
# document scans, service and refill receipts, workshop invoices, part photos —
|
||||
# in a SeaweedFS bucket instead of on the pb_data volume next to the database.
|
||||
# The database and PocketBase's own backups stay on pb_data. Clients cannot tell
|
||||
# the difference: an attachment has always been fetched through the API Server,
|
||||
# never from a storage URL.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
|
||||
services:
|
||||
seaweedfs:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs
|
||||
restart: unless-stopped
|
||||
# One process, four roles: master, volume, filer and the S3 gateway. -dir is
|
||||
# the only state it keeps.
|
||||
command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024
|
||||
environment:
|
||||
# SeaweedFS falls back to these when started without an -s3.config file,
|
||||
# and configuring one identity is what takes the S3 gateway out of its
|
||||
# default allow-anyone mode. The same credentials PocketBase authenticates
|
||||
# with below — one pair to set, in .env.
|
||||
AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
||||
AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
||||
volumes:
|
||||
# From here on the attachments live here, not on pb_data.
|
||||
- seaweed_data:/data
|
||||
ports:
|
||||
# The stack reaches the gateway over the compose network; this is here so
|
||||
# `aws s3 ls --endpoint-url http://localhost:8333` works while developing.
|
||||
- "${SEAWEED_S3_PORT:-8333}:8333"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
seaweedfs-init:
|
||||
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
||||
container_name: drivervault-seaweedfs-init
|
||||
# Runs once and exits. PocketBase never issues a CreateBucket of its own and
|
||||
# SeaweedFS will not conjure one on first upload, so something has to.
|
||||
# Creating a bucket that already exists is a no-op, so every later boot
|
||||
# passes straight through.
|
||||
restart: "no"
|
||||
depends_on:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
# `|| true` so a restart is never blocked by the shell's exit status: this
|
||||
# step is best-effort, and a gateway that is genuinely unreachable is
|
||||
# reported by the API Server's own S3 check at boot, with the reason.
|
||||
command:
|
||||
- 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true'
|
||||
|
||||
pocketbase:
|
||||
build:
|
||||
context: ./pocketbase
|
||||
image: drivervault-pocketbase
|
||||
container_name: drivervault-pocketbase
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# PocketBase is the process that reads and writes the objects, so the
|
||||
# gateway has to be serving before it is asked to store anything.
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Superuser is created/updated on boot so the API Server can authenticate.
|
||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||
volumes:
|
||||
- pb_data:/pb/pb_data
|
||||
ports:
|
||||
# Admin UI / API exposed on the host for management (http://host:8070/_/).
|
||||
- "${PB_PORT:-8070}:8070"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
api-server:
|
||||
build:
|
||||
context: ../API Server
|
||||
image: drivervault-api
|
||||
container_name: drivervault-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
# The bucket must exist before the bootstrap points PocketBase at it.
|
||||
seaweedfs-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
API_ADDR: ":8080"
|
||||
# Reach PocketBase by its service name on the internal network. Override
|
||||
# POCKETBASE_URL in .env to point the API Server at a database outside
|
||||
# this stack — that is also how you make a retarget done from the panel
|
||||
# permanent, since the panel's change lasts only for the container's life.
|
||||
POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}"
|
||||
POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||
# Probed by the panel status page. This is a server-to-server call inside
|
||||
# the compose network, so the default is the service name — plain
|
||||
# localhost:8090 would resolve to this container itself. Override
|
||||
# WEBAPP_URL in .env to make a change from the panel's Web App screen
|
||||
# permanent; the panel alone only holds it for the container's life.
|
||||
WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}"
|
||||
# Same-origin requests go through the Web App BFF, so CORS is only needed
|
||||
# if the browser ever calls the API Server directly. Default to the web origin.
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||
# Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars
|
||||
# the collections are still created but no app user is, leaving a stack
|
||||
# you cannot log into.
|
||||
#
|
||||
# Leave the bootstrap ON: a release can add collections or fields the
|
||||
# server needs, and a stack that skips it never gets them. The API Server
|
||||
# self-heals exactly one thing — app_settings, the collection holding the
|
||||
# plugin settings, which it creates on demand because it cannot serve the
|
||||
# plugin panel without it. Every other schema change still depends on this
|
||||
# flag. Turn it off only for a database you know matches the release.
|
||||
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||
# OCPP charger control (Anker Solix). Chargers are rejected unless they
|
||||
# connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is
|
||||
# terminated in front of this stack or for local dev on a trusted network.
|
||||
OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}"
|
||||
OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}"
|
||||
# Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the
|
||||
# charger publishes over Anker's broker, decoded ones and unreadable ones
|
||||
# alike, with their bytes. It is how a frame nobody has named gets named —
|
||||
# do something in the Anker app while a control read holds the connection
|
||||
# open, and read the frames back out of the log. Off by default: with it on
|
||||
# a charger under a live trigger writes a line every few seconds.
|
||||
ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}"
|
||||
# --- File storage --------------------------------------------------
|
||||
# Read by the API Server's bootstrap, which writes them into PocketBase's
|
||||
# settings on every boot, idempotently. Only record files move — scans,
|
||||
# receipts, invoices, part photos. The database and PocketBase's own
|
||||
# backups stay on pb_data.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
# The service name: a server-to-server call inside the compose network.
|
||||
PB_S3_ENDPOINT: "http://seaweedfs:8333"
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}"
|
||||
PB_S3_SECRET: "${PB_S3_SECRET}"
|
||||
# Path style, because a self-hosted gateway has no per-bucket DNS.
|
||||
PB_S3_FORCE_PATH_STYLE: "true"
|
||||
ports:
|
||||
# Optional direct access to the API Server (and its panel at /); the Web
|
||||
# App reaches it over the internal network, not this host port. Chargers
|
||||
# dialling /ocpp/{serial} also arrive here.
|
||||
- "${API_PORT:-8080}:8080"
|
||||
# No volume: the API Server keeps no state on disk — every setting it owns,
|
||||
# plugin settings included, lives in PocketBase under pb_data.
|
||||
healthcheck:
|
||||
# Declared here rather than relying only on the image's HEALTHCHECK, so the
|
||||
# depends_on gate below still works against an older pulled image.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
web-app:
|
||||
build:
|
||||
context: ../Web App
|
||||
args:
|
||||
# Empty -> bundle uses same-origin "/api", which the BFF proxies below.
|
||||
VITE_API_BASE: "${VITE_API_BASE:-}"
|
||||
image: drivervault-web
|
||||
container_name: drivervault-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
# The image now ships a HEALTHCHECK, so wait for the API Server to be
|
||||
# serving rather than merely started.
|
||||
api-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are
|
||||
# told to dial — to the API Server over the internal network.
|
||||
API_BASE: "http://api-server:8080"
|
||||
# Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a
|
||||
# charger arrived over TLS, so leave this off unless a TLS-terminating
|
||||
# proxy in front of the stack is the only way in: otherwise a charger could
|
||||
# claim wss over a plaintext connection. Set it to true when TLS ends at
|
||||
# that proxy and OCPP_PUBLIC_URL names a wss:// base through it.
|
||||
TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}"
|
||||
ports:
|
||||
- "${WEB_PORT:-8090}:8090"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
seaweed_data:
|
||||
@@ -1,61 +0,0 @@
|
||||
name: drivervault
|
||||
|
||||
# TLS overlay — put a public hostname and a real certificate in front of the
|
||||
# stack. Layer it on top of either base file:
|
||||
#
|
||||
# docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
|
||||
#
|
||||
# What it changes:
|
||||
# • Caddy terminates TLS on :443 and proxies everything to the Web App BFF,
|
||||
# which already carries /api/ and /ocpp/ through to the API Server. One
|
||||
# hostname serves browsers and chargers alike.
|
||||
# • The charger endpoint the panel hands out becomes wss://$DV_DOMAIN/ocpp/…,
|
||||
# so OCPP_REQUIRE_TLS goes back on and the control token stops crossing the
|
||||
# network in the clear.
|
||||
#
|
||||
# Requirements, none of which this file can arrange for you:
|
||||
# • DV_DOMAIN resolves to this host from the public internet, and ports 80 and
|
||||
# 443 reach it (Caddy needs :80 for the ACME challenge, and keeps it for the
|
||||
# redirect afterwards).
|
||||
# • The charger can resolve DV_DOMAIN too. On a LAN behind NAT that usually
|
||||
# means hairpin NAT or a split-DNS entry pointing the name at 10.2.1.10 —
|
||||
# otherwise the charger looks up a public address it cannot route to.
|
||||
|
||||
services:
|
||||
caddy:
|
||||
image: "${CADDY_IMAGE:-caddy:2-alpine}"
|
||||
container_name: drivervault-caddy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
web-app:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DV_DOMAIN: "${DV_DOMAIN:?set DV_DOMAIN in .env}"
|
||||
# Let's Encrypt sends expiry warnings here if renewal ever stops working.
|
||||
DV_ACME_EMAIL: "${DV_ACME_EMAIL:?set DV_ACME_EMAIL in .env}"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
# Certificates live here. Keep the volume: wiping it re-issues on next
|
||||
# boot and Let's Encrypt rate-limits that.
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
api-server:
|
||||
environment:
|
||||
# Derived from the one hostname, so there is a single thing to set.
|
||||
OCPP_REQUIRE_TLS: "true"
|
||||
OCPP_PUBLIC_URL: "wss://${DV_DOMAIN}"
|
||||
CORS_ALLOW_ORIGINS: "https://${DV_DOMAIN}"
|
||||
|
||||
web-app:
|
||||
environment:
|
||||
# Caddy terminates TLS and says so in X-Forwarded-Proto. Believe it —
|
||||
# that header is now set by the proxy in front, not by whoever dialed in.
|
||||
TRUST_FORWARDED_PROTO: "true"
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
+48
-18
@@ -92,25 +92,55 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an
|
||||
workshop visit, refill, charge, document and part (PDF or image, up to 10MB). Picked
|
||||
with `file_picker`, fetched back through the API Server — never a public URL —
|
||||
and opened with the phone's own viewer via `open_filex`.
|
||||
- **Charging** — mirrors the web `Charging.vue`, split into two tabs. **Public**
|
||||
is a discovery map with a demo session and nearby stations: presentational
|
||||
placeholders, because there is no public-charging API yet (same as the web).
|
||||
**Home** is not a placeholder. It lists the chargers you own — imported from a
|
||||
service you connected, the same move the garage makes for a car — and above
|
||||
them four cards about the one you picked: **control** (start/stop, a current
|
||||
limit, boost or reset depending on the transport), **connection** (which
|
||||
charger, and either its serial or its address on your network), **readings**
|
||||
(everything the charger reports over Modbus — per-phase power, its settings,
|
||||
what it is, and any alarm), and **information** (everything the record holds,
|
||||
with the service's live view of whether it is reachable). Control needs a mode
|
||||
picked under Settings → Integrations — Modbus TCP over the local network, or
|
||||
Own/Proxy CSMS over OCPP — but the information card stands without one.
|
||||
Each card folds away, remembered per device; the tabs and the cards rearrange
|
||||
from the ⇅ button in the app bar, and that arrangement is saved on your
|
||||
profile, so it follows the account the way the garage order does.
|
||||
- **Charging** — mirrors the web `Charging.vue`, split into three tabs.
|
||||
**Public** is a discovery map with a demo session and nearby stations:
|
||||
presentational placeholders, because there is no public-charging API yet (same
|
||||
as the web). **Home** is not a placeholder. It lists the chargers you own —
|
||||
imported from a service you connected, the same move the garage makes for a
|
||||
car — and above them six cards about the one you picked:
|
||||
- **control** — start/stop, boost, skip a start delay, reboot, and the current
|
||||
limit on the one transport that has nowhere else to put it. It opens with
|
||||
the charger it acts on, picture and name, because the buttons drive whichever
|
||||
serial is in force and that is not always the record highlighted below.
|
||||
- **RFID cards** — who may start a charge without a phone. The list the
|
||||
account holds, a card added by number or by holding it against the charger's
|
||||
own reader (the reader opens for twenty seconds and the number arrives on its
|
||||
own), and the charger's own list read back from the device, which is the half
|
||||
that actually decides whether a card opens it. When the two disagree the card
|
||||
says which list each card is missing from.
|
||||
- **charger settings** — what the charger is set to, written back. Over Modbus
|
||||
that is four registers (the current limit, phase count, boost, the control
|
||||
timeout); over the Anker cloud it is the charger's whole settings group, in
|
||||
sections — charging, schedule, load balancing, solar, panel and light, local
|
||||
network — one write per section, because the charger takes a command whole.
|
||||
- **connection** — which charger, and either its serial or its address on your
|
||||
network.
|
||||
- **readings** — everything the charger reports: per-phase power, its
|
||||
settings, what it is, and any alarm.
|
||||
- **information** — everything the record holds, with the service's live view
|
||||
of whether it is reachable, the fields the service sent under its own names,
|
||||
and each of the account's per-charger views read a record at a time.
|
||||
|
||||
Control needs a mode picked under Settings → Integrations — Modbus TCP over
|
||||
the local network, the Anker cloud, or Own/Proxy CSMS over OCPP — but the
|
||||
information and RFID cards stand without one. Each card folds away, remembered
|
||||
per device; the tabs and the cards rearrange from the ⇅ button in the app bar,
|
||||
and that arrangement is saved on your profile, so it follows the account the
|
||||
way the garage order does. A card added after you arranged yours appears beside
|
||||
the neighbour it was written to sit under, rather than at the bottom.
|
||||
|
||||
**Scheduler** is the third tab: one list of charging tasks covering every
|
||||
charger you own, where the charger's own cloud schedule is one window inside
|
||||
one box. A task is a whole flow — start at 23:00, cap to 10 A at 01:00, stop at
|
||||
06:30 — on the days you pick and the chargers you pick, named once and switched
|
||||
on and off as one. Naming no charger means every charger you own, including the
|
||||
ones you import later. The clock is the server's, so a task fires whether or
|
||||
not the app is open; each row says how its last firing went, and any step can
|
||||
be fired now to find out whether it will reach the charger before the night it
|
||||
matters.
|
||||
- **Settings** — account (name / email verification / password), appearance
|
||||
(theme + dark mode, **language**, **region**, date format, **currency**, font
|
||||
size), profile (avatar via `image_picker`, bio), **integrations** (Toyota,
|
||||
(theme + dark mode, **language**, **region**, date format, **time format**,
|
||||
**first day of the week**, **currency**, font size), profile (avatar via `image_picker`, bio), **integrations** (Toyota,
|
||||
Anker Solix), **Security** (biometric toggle), **Organization** (create your
|
||||
own — which makes you its admin — or rename/delete the one you administer),
|
||||
**data export/import**, and the account-deletion state machine. Export writes
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
"title": "Ladere i nærheden",
|
||||
"tabs": {
|
||||
"public": "Offentlige ladere",
|
||||
"home": "Hjemmeladere"
|
||||
"home": "Hjemmeladere",
|
||||
"scheduler": "Planlægning af hjemmelader"
|
||||
},
|
||||
"arrange": {
|
||||
"title": "Indret opladningssiden",
|
||||
@@ -281,7 +282,29 @@
|
||||
"ocpp1": "Forbinder",
|
||||
"ocpp2": "Forbundet",
|
||||
"mqtt0": "Ikke forbundet",
|
||||
"mqtt1": "Forbundet"
|
||||
"mqtt1": "Forbundet",
|
||||
"settingsTitle": "Laderindstillinger",
|
||||
"apply": "Anvend",
|
||||
"turnOn": "Slå til",
|
||||
"turnOff": "Slå fra",
|
||||
"limitFloorHint": "{amps} A er bunden — derunder holder laderen pause i stedet for at lade langsomt.",
|
||||
"boostHint": "Kun den aktuelle session; laderen rydder det, når sessionen slutter.",
|
||||
"timeoutHint": "Mindst {n} sekunder. Laderen falder tilbage til sin egen strategi, hvis intet skriver inden for tiden.",
|
||||
"settingsReported": "Rapporteret, kan ikke indstilles",
|
||||
"settingsReportedHint": "Laderen rapporterer disse; Modbus-kortet har intet register til at skrive dem. Ret dem i Anker-appen.",
|
||||
"blockCharging": "Opladning",
|
||||
"blockSchedule": "Tidsplan",
|
||||
"blockBalancing": "Belastningsbalancering",
|
||||
"blockSolar": "Sol",
|
||||
"blockPanel": "Panel og lys",
|
||||
"blockLocal": "Lokalt netværk",
|
||||
"windowStart": "Start",
|
||||
"windowEnd": "Slut",
|
||||
"reset": "Fortryd",
|
||||
"cloudSettingsHint": "Laderens egne indstillinger, skrevet via Anker-skyen. Et afsnit er én kommando til laderen, så dets felter anvendes samlet.",
|
||||
"cloudSettingsReportedHint": "Laderen rapporterer disse; ingen kommando skriver dem. Hvad de to tilstande og flaget vælger, er udokumenteret, så de vises som de tal, de er.",
|
||||
"modbusOffWarning": "Med Modbus TCP-serveren slået fra svarer laderen ikke længere på det lokale netværk, og Modbus-styringstilstanden har intet at ringe op.",
|
||||
"chargingStatus": "Ladestatus"
|
||||
},
|
||||
"info": {
|
||||
"title": "Laderoplysninger",
|
||||
@@ -346,7 +369,46 @@
|
||||
"offline": "Offline",
|
||||
"refresh": "Opdater",
|
||||
"rawTitle": "Som tjenesten melder det",
|
||||
"rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt."
|
||||
"rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt.",
|
||||
"nickname": "Kaldenavn",
|
||||
"productCode": "Produktkode",
|
||||
"deviceType": "Enhedstype",
|
||||
"charging": "Oplader",
|
||||
"statusCode": "Statuskode",
|
||||
"ocppLink": "OCPP-forbindelse",
|
||||
"wifiOnline": "Wi-Fi forbundet",
|
||||
"bleId": "Bluetooth-id",
|
||||
"blePassword": "Bluetooth-parringskode",
|
||||
"ownerId": "Ejer-id",
|
||||
"fields": {
|
||||
"email": "E-mail",
|
||||
"serial": "Serienummer",
|
||||
"memberId": "Medlems-id",
|
||||
"memberType": "Medlemstype",
|
||||
"userId": "Bruger-id",
|
||||
"status": "Status",
|
||||
"inviteLimit": "Invitationsgrænse",
|
||||
"sessions": "Sessioner",
|
||||
"chargeTime": "Opladningstid",
|
||||
"energy": "Opladet energi",
|
||||
"co2Saved": "CO2 sparet",
|
||||
"cost": "Omkostning",
|
||||
"costSaved": "Sparet beløb",
|
||||
"currency": "Valuta",
|
||||
"mileage": "Kilometertal",
|
||||
"page": "Side",
|
||||
"perPage": "Pr. side",
|
||||
"records": "Poster",
|
||||
"from": "Fra",
|
||||
"source": "Kilde",
|
||||
"timeZone": "Tidszone",
|
||||
"updated": "Opdateret",
|
||||
"added": "Tilføjet",
|
||||
"address": "Adresse",
|
||||
"name": "Navn",
|
||||
"cardName": "Kortnavn",
|
||||
"cardNumber": "Kortnummer"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"count": {
|
||||
@@ -369,6 +431,60 @@
|
||||
},
|
||||
"free": "{avail} af {total} ledige",
|
||||
"full": "Optaget"
|
||||
},
|
||||
"rfid": {
|
||||
"title": "RFID-kortindstillinger",
|
||||
"none": "Ingen kort er godkendt til denne lader.",
|
||||
"unsupported": "Tjenesten, som denne lader kommer fra, rapporterer ikke RFID-kort.",
|
||||
"add": "Tilføj kort",
|
||||
"tap": "Hold kortet mod laderen",
|
||||
"tapping": "Hold kortet mod læseren… {n}s",
|
||||
"tapHint": "Læseren er åben. Hold kortet mod laderen.",
|
||||
"tapSave": "Hold kortet mod laderen, og tilføj det",
|
||||
"tapSaveHint": "Tilføjer kortet, så snart det holdes mod læseren, med navnet RFID og kortets sidste fire cifre.",
|
||||
"tapNone": "Der blev ikke holdt et kort mod læseren, før den lukkede.",
|
||||
"addTitle": "Tilføj et kort",
|
||||
"remove": "Fjern",
|
||||
"removeConfirm": "Fjern {name} fra denne lader?",
|
||||
"numberPlaceholder": "Kortnummer",
|
||||
"namePlaceholder": "Navn (valgfrit)",
|
||||
"notAdded": "Tjenesten tog imod anmodningen, men kortet er ikke på laderen. Kontrollér nummeret, og prøv igen.",
|
||||
"notRemoved": "Tjenesten tog imod anmodningen, men kortet er stadig på laderen.",
|
||||
"readCharger": "Læs laderens egen liste",
|
||||
"chargerTitle": "På selve laderen",
|
||||
"chargerNone": "Laderen har ingen kort.",
|
||||
"chargerHint": "Spurgt laderen, ikke kontoen. Den svarer kun med numre — et korts navn hører til på kontoen.",
|
||||
"driftTitle": "De to lister er ikke enige",
|
||||
"onlyOnCharger": "Åbner laderen, men findes ikke på kontoen: {cards}",
|
||||
"onlyOnAccount": "På kontoen, men ikke på laderen, så det åbner den ikke: {cards}",
|
||||
"inferred": "Anker dokumenterer hverken tilføjelse eller fjernelse. DriverVault udleder anmodningen af de felter, kortlisten svarer med, og læser derefter listen igen — det, du ser ovenfor, er det, kontoen har."
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "Ladeopgaver",
|
||||
"subtitle": "Én plan for alle dine ladere. En opgave er et forløb — start, grænse, stop — der kører på de dage du vælger, på de ladere du vælger.",
|
||||
"add": "Ny opgave",
|
||||
"empty": "Ingen opgaver endnu. En opgave er et helt forløb: start kl. 23:00, begræns til 10 A kl. 01:00, stop kl. 06:30.",
|
||||
"needCharger": "Importér først en lader under Hjemmeladere — en opgave skal have noget at handle på.",
|
||||
"serverHint": "Opgaverne kører på serveren, så de udføres uanset om denne side er åben. Tidspunkter læses i den tidszone, du skrev dem i.",
|
||||
"allChargers": "Alle ladere",
|
||||
"missingChargers": "Ingen lader på kontoen længere",
|
||||
"everyDay": "Hver dag",
|
||||
"runNow": "Kør nu",
|
||||
"running": "Sender…",
|
||||
"lastRun": "Sidst kørt {when}",
|
||||
"noResult": "intet resultat registreret",
|
||||
"toggleHint": "Om uret udløser denne opgave.",
|
||||
"removeConfirm": "Slet opgaven “{name}”?",
|
||||
"taskCount": {
|
||||
"one": "{n} opgave",
|
||||
"other": "{n} opgaver"
|
||||
},
|
||||
"actions": {
|
||||
"start": "Start opladning",
|
||||
"stop": "Stop opladning",
|
||||
"limit": "Sæt strømgrænse",
|
||||
"boost": "Boost sessionen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -415,7 +531,17 @@
|
||||
"fontSize": "Skriftstørrelse",
|
||||
"fontSmall": "Lille",
|
||||
"fontMedium": "Mellem",
|
||||
"fontLarge": "Stor"
|
||||
"fontLarge": "Stor",
|
||||
"timeFormat": "Tidsformat",
|
||||
"timeAuto": "Følg regionen",
|
||||
"time24": "24-timers",
|
||||
"time12": "12-timers",
|
||||
"timeExample": "Eksempel: {example}",
|
||||
"weekStart": "Første dag i ugen",
|
||||
"weekAuto": "Følg regionen",
|
||||
"weekMonday": "Mandag",
|
||||
"weekSunday": "Søndag",
|
||||
"weekExample": "Eksempel: {example}"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
@@ -1167,6 +1293,25 @@
|
||||
"alreadyImported": "Allerede importeret",
|
||||
"name": "Ladernavn",
|
||||
"submit": "Importer lader"
|
||||
},
|
||||
"chargingTask": {
|
||||
"title": "Ny ladeopgave",
|
||||
"editTitle": "Rediger ladeopgave",
|
||||
"name": "Navn",
|
||||
"namePlaceholder": "Nattakst",
|
||||
"time": "Kl.",
|
||||
"flow": "Forløbet",
|
||||
"flowHint": "Hvert trin udføres på sit eget tidspunkt, hver dag opgaven kører. En hel nat er én opgave: start kl. 23:00, stop kl. 06:30.",
|
||||
"addStep": "+ Tilføj et trin",
|
||||
"removeStep": "Fjern dette trin",
|
||||
"amps": "Strømgrænse",
|
||||
"ampsHint": "6 A er bundgrænsen — derunder sætter laderen på pause i stedet for at lade langsomt.",
|
||||
"chargers": "På disse ladere",
|
||||
"allChargers": "Alle mine ladere",
|
||||
"allChargersHint": "Inklusive ladere du importerer senere.",
|
||||
"noChargers": "Ingen ladere på kontoen endnu.",
|
||||
"days": "På disse dage",
|
||||
"everyDay": "Hver dag"
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
"title": "Nearby chargers",
|
||||
"tabs": {
|
||||
"public": "Public chargers",
|
||||
"home": "Home chargers"
|
||||
"home": "Home chargers",
|
||||
"scheduler": "Home charger scheduler"
|
||||
},
|
||||
"arrange": {
|
||||
"title": "Arrange the charging page",
|
||||
@@ -281,7 +282,29 @@
|
||||
"ocpp1": "Connecting",
|
||||
"ocpp2": "Connected",
|
||||
"mqtt0": "Not connected",
|
||||
"mqtt1": "Connected"
|
||||
"mqtt1": "Connected",
|
||||
"settingsTitle": "Charger settings",
|
||||
"apply": "Apply",
|
||||
"turnOn": "Turn on",
|
||||
"turnOff": "Turn off",
|
||||
"limitFloorHint": "{amps} A is the floor — below it the charger pauses rather than charging slowly.",
|
||||
"boostHint": "The current session only; the charger clears it when the session ends.",
|
||||
"timeoutHint": "At least {n} seconds. The charger falls back to its own strategy if nothing writes within it.",
|
||||
"settingsReported": "Reported, not settable",
|
||||
"settingsReportedHint": "The charger reports these; the Modbus map has no register to write them. Change them in the Anker app.",
|
||||
"blockCharging": "Charging",
|
||||
"blockSchedule": "Schedule",
|
||||
"blockBalancing": "Load balancing",
|
||||
"blockSolar": "Solar",
|
||||
"blockPanel": "Panel and light",
|
||||
"blockLocal": "Local network",
|
||||
"windowStart": "Start",
|
||||
"windowEnd": "End",
|
||||
"reset": "Undo",
|
||||
"cloudSettingsHint": "The charger's own settings, written over the Anker cloud. A section is one command to the charger, so its fields are applied together.",
|
||||
"cloudSettingsReportedHint": "The charger reports these; no command writes them. What the two modes and the flag select is undocumented, so they are shown as the numbers they are.",
|
||||
"modbusOffWarning": "With the Modbus TCP server off the charger stops answering on the local network, and the Modbus control mode has nothing left to dial.",
|
||||
"chargingStatus": "Charging status"
|
||||
},
|
||||
"info": {
|
||||
"title": "Charger information",
|
||||
@@ -346,7 +369,46 @@
|
||||
"offline": "Offline",
|
||||
"refresh": "Refresh",
|
||||
"rawTitle": "As the service reports it",
|
||||
"rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed."
|
||||
"rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed.",
|
||||
"nickname": "Nickname",
|
||||
"productCode": "Product code",
|
||||
"deviceType": "Device type",
|
||||
"charging": "Charging",
|
||||
"statusCode": "Status code",
|
||||
"ocppLink": "OCPP connection",
|
||||
"wifiOnline": "Wi-Fi connected",
|
||||
"bleId": "Bluetooth id",
|
||||
"blePassword": "Bluetooth pairing code",
|
||||
"ownerId": "Owner id",
|
||||
"fields": {
|
||||
"email": "Email",
|
||||
"serial": "Serial",
|
||||
"memberId": "Member id",
|
||||
"memberType": "Member type",
|
||||
"userId": "User id",
|
||||
"status": "Status",
|
||||
"inviteLimit": "Invite limit",
|
||||
"sessions": "Sessions",
|
||||
"chargeTime": "Time charging",
|
||||
"energy": "Energy charged",
|
||||
"co2Saved": "CO2 saved",
|
||||
"cost": "Cost",
|
||||
"costSaved": "Cost saved",
|
||||
"currency": "Currency",
|
||||
"mileage": "Mileage",
|
||||
"page": "Page",
|
||||
"perPage": "Per page",
|
||||
"records": "Records",
|
||||
"from": "From",
|
||||
"source": "Source",
|
||||
"timeZone": "Time zone",
|
||||
"updated": "Updated",
|
||||
"added": "Added",
|
||||
"address": "Address",
|
||||
"name": "Name",
|
||||
"cardName": "Card name",
|
||||
"cardNumber": "Card number"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"count": {
|
||||
@@ -369,6 +431,60 @@
|
||||
},
|
||||
"free": "{avail} of {total} free",
|
||||
"full": "Full"
|
||||
},
|
||||
"rfid": {
|
||||
"title": "RFID cards settings",
|
||||
"none": "No cards are authorised on this charger.",
|
||||
"unsupported": "The service this charger came from does not report RFID cards.",
|
||||
"add": "Add card",
|
||||
"tap": "Tap card at the charger",
|
||||
"tapping": "Hold the card against the reader… {n}s",
|
||||
"tapHint": "The reader is open. Hold the card against the charger.",
|
||||
"tapSave": "Tap card and add it",
|
||||
"tapSaveHint": "Adds the card as soon as it is tapped, named RFID and its last four digits.",
|
||||
"tapNone": "No card was tapped before the reader closed.",
|
||||
"addTitle": "Add a card",
|
||||
"remove": "Remove",
|
||||
"removeConfirm": "Remove {name} from this charger?",
|
||||
"numberPlaceholder": "Card number",
|
||||
"namePlaceholder": "Name (optional)",
|
||||
"notAdded": "The service took the request, but the card is not on the charger. Check the number and try again.",
|
||||
"notRemoved": "The service took the request, but the card is still on the charger.",
|
||||
"readCharger": "Read the charger's own list",
|
||||
"chargerTitle": "On the charger itself",
|
||||
"chargerNone": "The charger holds no cards.",
|
||||
"chargerHint": "Asked of the charger, not of the account. It answers with numbers only — a card's name lives on the account.",
|
||||
"driftTitle": "The two lists disagree",
|
||||
"onlyOnCharger": "Opens the charger but is not on the account: {cards}",
|
||||
"onlyOnAccount": "On the account but not on the charger, so it will not open it: {cards}",
|
||||
"inferred": "Anker documents neither the add nor the remove endpoint. DriverVault infers the request from the fields the card list answers with, then reads the list back — what you see above is what the account holds."
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "Charging tasks",
|
||||
"subtitle": "One schedule for every charger you own. A task is a flow — start, limit, stop — running on the days you pick, on the chargers you pick.",
|
||||
"add": "New task",
|
||||
"empty": "No tasks yet. A task is a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30.",
|
||||
"needCharger": "Import a charger under Home chargers first — a task needs something to act on.",
|
||||
"serverHint": "Tasks run on the server, so they fire whether or not this page is open. Times are read in the time zone you wrote them in.",
|
||||
"allChargers": "All chargers",
|
||||
"missingChargers": "No charger on your account any more",
|
||||
"everyDay": "Every day",
|
||||
"runNow": "Run now",
|
||||
"running": "Sending…",
|
||||
"lastRun": "Last run {when}",
|
||||
"noResult": "no result recorded",
|
||||
"toggleHint": "Whether the clock fires this task.",
|
||||
"removeConfirm": "Delete the task “{name}”?",
|
||||
"taskCount": {
|
||||
"one": "{n} task",
|
||||
"other": "{n} tasks"
|
||||
},
|
||||
"actions": {
|
||||
"start": "Start charging",
|
||||
"stop": "Stop charging",
|
||||
"limit": "Set current limit",
|
||||
"boost": "Boost the session"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -528,7 +644,17 @@
|
||||
"fontSize": "Font size",
|
||||
"fontSmall": "Small",
|
||||
"fontMedium": "Medium",
|
||||
"fontLarge": "Large"
|
||||
"fontLarge": "Large",
|
||||
"timeFormat": "Time format",
|
||||
"timeAuto": "Follow the region",
|
||||
"time24": "24-hour",
|
||||
"time12": "12-hour",
|
||||
"timeExample": "Example: {example}",
|
||||
"weekStart": "First day of the week",
|
||||
"weekAuto": "Follow the region",
|
||||
"weekMonday": "Monday",
|
||||
"weekSunday": "Sunday",
|
||||
"weekExample": "Example: {example}"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
@@ -1167,6 +1293,25 @@
|
||||
"alreadyImported": "Already imported",
|
||||
"name": "Charger name",
|
||||
"submit": "Import charger"
|
||||
},
|
||||
"chargingTask": {
|
||||
"title": "New charging task",
|
||||
"editTitle": "Edit charging task",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Night rate",
|
||||
"time": "At",
|
||||
"flow": "The flow",
|
||||
"flowHint": "Each step fires at its own time, every day the task runs. A whole night is one task: start at 23:00, stop at 06:30.",
|
||||
"addStep": "+ Add a step",
|
||||
"removeStep": "Remove this step",
|
||||
"amps": "Current limit",
|
||||
"ampsHint": "6 A is the floor — below it the charger pauses rather than charging slowly.",
|
||||
"chargers": "On these chargers",
|
||||
"allChargers": "All my chargers",
|
||||
"allChargersHint": "Including any charger you import later.",
|
||||
"noChargers": "No chargers on your account yet.",
|
||||
"days": "On these days",
|
||||
"everyDay": "Every day"
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"title": "Ładowarki w pobliżu",
|
||||
"tabs": {
|
||||
"public": "Ładowarki publiczne",
|
||||
"home": "Ładowarki domowe"
|
||||
"home": "Ładowarki domowe",
|
||||
"scheduler": "Harmonogram ładowarki"
|
||||
},
|
||||
"arrange": {
|
||||
"title": "Ułóż stronę ładowania",
|
||||
@@ -283,7 +284,29 @@
|
||||
"ocpp1": "Łączenie",
|
||||
"ocpp2": "Połączona",
|
||||
"mqtt0": "Nierozłączona",
|
||||
"mqtt1": "Połączona"
|
||||
"mqtt1": "Połączona",
|
||||
"settingsTitle": "Ustawienia ładowarki",
|
||||
"apply": "Zastosuj",
|
||||
"turnOn": "Włącz",
|
||||
"turnOff": "Wyłącz",
|
||||
"limitFloorHint": "{amps} A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
|
||||
"boostHint": "Tylko bieżąca sesja; ładowarka kasuje to po jej zakończeniu.",
|
||||
"timeoutHint": "Co najmniej {n} sekund. Bez zapisu w tym czasie ładowarka wraca do własnej strategii.",
|
||||
"settingsReported": "Raportowane, nieustawialne",
|
||||
"settingsReportedHint": "Ładowarka je raportuje; mapa Modbus nie ma rejestru do ich zapisu. Zmień je w aplikacji Anker.",
|
||||
"blockCharging": "Ładowanie",
|
||||
"blockSchedule": "Harmonogram",
|
||||
"blockBalancing": "Balansowanie obciążenia",
|
||||
"blockSolar": "Fotowoltaika",
|
||||
"blockPanel": "Panel i podświetlenie",
|
||||
"blockLocal": "Sieć lokalna",
|
||||
"windowStart": "Początek",
|
||||
"windowEnd": "Koniec",
|
||||
"reset": "Cofnij",
|
||||
"cloudSettingsHint": "Własne ustawienia ładowarki, zapisywane przez chmurę Anker. Jedna sekcja to jedno polecenie do ładowarki, więc jej pola są zapisywane razem.",
|
||||
"cloudSettingsReportedHint": "Ładowarka je zgłasza, ale żadne polecenie ich nie zapisuje. Nie wiadomo, co wybierają te dwa tryby i flaga, więc pokazane są jako liczby, którymi są.",
|
||||
"modbusOffWarning": "Przy wyłączonym serwerze Modbus TCP ładowarka przestaje odpowiadać w sieci lokalnej, a tryb sterowania Modbus nie ma już pod co zadzwonić.",
|
||||
"chargingStatus": "Status ładowania"
|
||||
},
|
||||
"info": {
|
||||
"title": "Informacje o ładowarce",
|
||||
@@ -348,7 +371,46 @@
|
||||
"offline": "Offline",
|
||||
"refresh": "Odśwież",
|
||||
"rawTitle": "Tak, jak podaje to usługa",
|
||||
"rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw."
|
||||
"rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw.",
|
||||
"nickname": "Nazwa własna",
|
||||
"productCode": "Kod produktu",
|
||||
"deviceType": "Typ urządzenia",
|
||||
"charging": "Ładowanie",
|
||||
"statusCode": "Kod statusu",
|
||||
"ocppLink": "Połączenie OCPP",
|
||||
"wifiOnline": "Wi-Fi połączone",
|
||||
"bleId": "Identyfikator Bluetooth",
|
||||
"blePassword": "Kod parowania Bluetooth",
|
||||
"ownerId": "Identyfikator właściciela",
|
||||
"fields": {
|
||||
"email": "E-mail",
|
||||
"serial": "Numer seryjny",
|
||||
"memberId": "Identyfikator członka",
|
||||
"memberType": "Typ członka",
|
||||
"userId": "Identyfikator użytkownika",
|
||||
"status": "Status",
|
||||
"inviteLimit": "Limit zaproszeń",
|
||||
"sessions": "Sesje",
|
||||
"chargeTime": "Czas ładowania",
|
||||
"energy": "Naładowana energia",
|
||||
"co2Saved": "Oszczędność CO2",
|
||||
"cost": "Koszt",
|
||||
"costSaved": "Oszczędność kosztów",
|
||||
"currency": "Waluta",
|
||||
"mileage": "Przebieg",
|
||||
"page": "Strona",
|
||||
"perPage": "Na stronę",
|
||||
"records": "Rekordy",
|
||||
"from": "Od",
|
||||
"source": "Źródło",
|
||||
"timeZone": "Strefa czasowa",
|
||||
"updated": "Zaktualizowano",
|
||||
"added": "Dodano",
|
||||
"address": "Adres",
|
||||
"name": "Nazwa",
|
||||
"cardName": "Nazwa karty",
|
||||
"cardNumber": "Numer karty"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"count": {
|
||||
@@ -375,6 +437,62 @@
|
||||
},
|
||||
"free": "{avail} z {total} wolnych",
|
||||
"full": "Zajęte"
|
||||
},
|
||||
"rfid": {
|
||||
"title": "Ustawienia kart RFID",
|
||||
"none": "Na tej ładowarce nie autoryzowano żadnej karty.",
|
||||
"unsupported": "Usługa, z której pochodzi ta ładowarka, nie zgłasza kart RFID.",
|
||||
"add": "Dodaj kartę",
|
||||
"tap": "Przyłóż kartę do ładowarki",
|
||||
"tapping": "Przytrzymaj kartę przy czytniku… {n}s",
|
||||
"tapHint": "Czytnik jest otwarty. Przytrzymaj kartę przy ładowarce.",
|
||||
"tapSave": "Przyłóż kartę i dodaj ją",
|
||||
"tapSaveHint": "Dodaje kartę zaraz po przyłożeniu, pod nazwą RFID i cztery ostatnie znaki numeru.",
|
||||
"tapNone": "Nie przyłożono karty, zanim czytnik się zamknął.",
|
||||
"addTitle": "Dodaj kartę",
|
||||
"remove": "Usuń",
|
||||
"removeConfirm": "Usunąć {name} z tej ładowarki?",
|
||||
"numberPlaceholder": "Numer karty",
|
||||
"namePlaceholder": "Nazwa (opcjonalnie)",
|
||||
"notAdded": "Usługa przyjęła żądanie, ale karty nie ma na ładowarce. Sprawdź numer i spróbuj ponownie.",
|
||||
"notRemoved": "Usługa przyjęła żądanie, ale karta nadal jest na ładowarce.",
|
||||
"readCharger": "Odczytaj własną listę ładowarki",
|
||||
"chargerTitle": "Na samej ładowarce",
|
||||
"chargerNone": "Ładowarka nie ma żadnych kart.",
|
||||
"chargerHint": "Zapytana została ładowarka, nie konto. Odpowiada samymi numerami — nazwa karty jest po stronie konta.",
|
||||
"driftTitle": "Obie listy się nie zgadzają",
|
||||
"onlyOnCharger": "Otwiera ładowarkę, ale nie ma jej na koncie: {cards}",
|
||||
"onlyOnAccount": "Jest na koncie, ale nie na ładowarce, więc jej nie otworzy: {cards}",
|
||||
"inferred": "Anker nie dokumentuje ani dodawania, ani usuwania. DriverVault wnioskuje żądanie z pól, którymi odpowiada lista kart, a potem odczytuje listę ponownie — powyżej widzisz to, co ma konto."
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "Zadania ładowania",
|
||||
"subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Zadanie to przebieg — start, limit, stop — wykonywany w wybrane dni, na wybranych ładowarkach.",
|
||||
"add": "Nowe zadanie",
|
||||
"empty": "Brak zadań. Zadanie to cały przebieg: start o 23:00, ograniczenie do 10 A o 01:00, stop o 06:30.",
|
||||
"needCharger": "Najpierw zaimportuj ładowarkę w zakładce Ładowarki domowe — zadanie musi mieć na czym działać.",
|
||||
"serverHint": "Zadania działają na serwerze, więc uruchamiają się niezależnie od tego, czy ta strona jest otwarta. Godziny są odczytywane w strefie czasowej, w której je zapisano.",
|
||||
"allChargers": "Wszystkie ładowarki",
|
||||
"missingChargers": "Nie ma już takiej ładowarki na koncie",
|
||||
"everyDay": "Codziennie",
|
||||
"runNow": "Uruchom teraz",
|
||||
"running": "Wysyłanie…",
|
||||
"lastRun": "Ostatnio {when}",
|
||||
"noResult": "brak zapisanego wyniku",
|
||||
"toggleHint": "Czy zegar uruchamia to zadanie.",
|
||||
"removeConfirm": "Usunąć zadanie „{name}”?",
|
||||
"taskCount": {
|
||||
"one": "{n} zadanie",
|
||||
"few": "{n} zadania",
|
||||
"many": "{n} zadań",
|
||||
"other": "{n} zadania"
|
||||
},
|
||||
"actions": {
|
||||
"start": "Rozpocznij ładowanie",
|
||||
"stop": "Zatrzymaj ładowanie",
|
||||
"limit": "Ustaw limit prądu",
|
||||
"boost": "Przyspiesz sesję"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -421,7 +539,17 @@
|
||||
"fontSize": "Rozmiar czcionki",
|
||||
"fontSmall": "Mała",
|
||||
"fontMedium": "Średnia",
|
||||
"fontLarge": "Duża"
|
||||
"fontLarge": "Duża",
|
||||
"timeFormat": "Format godziny",
|
||||
"timeAuto": "Jak w regionie",
|
||||
"time24": "24-godzinny",
|
||||
"time12": "12-godzinny",
|
||||
"timeExample": "Przykład: {example}",
|
||||
"weekStart": "Pierwszy dzień tygodnia",
|
||||
"weekAuto": "Zgodnie z regionem",
|
||||
"weekMonday": "Poniedziałek",
|
||||
"weekSunday": "Niedziela",
|
||||
"weekExample": "Przykład: {example}"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
@@ -1185,6 +1313,25 @@
|
||||
"alreadyImported": "Już zaimportowana",
|
||||
"name": "Nazwa ładowarki",
|
||||
"submit": "Importuj ładowarkę"
|
||||
},
|
||||
"chargingTask": {
|
||||
"title": "Nowe zadanie ładowania",
|
||||
"editTitle": "Edytuj zadanie ładowania",
|
||||
"name": "Nazwa",
|
||||
"namePlaceholder": "Taryfa nocna",
|
||||
"time": "O godzinie",
|
||||
"flow": "Przebieg",
|
||||
"flowHint": "Każdy krok uruchamia się o własnej godzinie, w każdy dzień działania zadania. Cała noc to jedno zadanie: start o 23:00, stop o 06:30.",
|
||||
"addStep": "+ Dodaj krok",
|
||||
"removeStep": "Usuń ten krok",
|
||||
"amps": "Limit prądu",
|
||||
"ampsHint": "6 A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
|
||||
"chargers": "Na tych ładowarkach",
|
||||
"allChargers": "Wszystkie moje ładowarki",
|
||||
"allChargersHint": "Łącznie z ładowarkami zaimportowanymi później.",
|
||||
"noChargers": "Na koncie nie ma jeszcze ładowarek.",
|
||||
"days": "W te dni",
|
||||
"everyDay": "Codziennie"
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
|
||||
@@ -652,6 +652,50 @@ class ApiClient {
|
||||
return ChargerDetails.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
// The RFID cards on one charger — the only calls in this client that change
|
||||
// anything on the Anker account. Anker documents neither endpoint, so the
|
||||
// server infers the request and then reads the list back: both of these answer
|
||||
// with {present, cards}, and it is the list that says what happened, not the
|
||||
// status code.
|
||||
Future<RfidCardWrite> saveAnkerRfidCard(String sn, String cardNumber, String cardName) async {
|
||||
final data = await _send(
|
||||
"POST",
|
||||
"/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards",
|
||||
body: {"cardNumber": cardNumber, "cardName": cardName},
|
||||
);
|
||||
if (data is! Map) return const RfidCardWrite();
|
||||
return RfidCardWrite.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
/// Opens the charger's own card reader and waits for a tap — the request is in
|
||||
/// flight for the whole twenty-second window, and answers whether or not a
|
||||
/// card arrived.
|
||||
Future<RfidScan> scanAnkerRfidCard(String sn) async {
|
||||
final data = await _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/scan");
|
||||
if (data is! Map) return const RfidScan();
|
||||
return RfidScan.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
Future<RfidCardWrite> deleteAnkerRfidCard(String sn, String cardNumber) async {
|
||||
final data = await _send(
|
||||
"DELETE",
|
||||
"/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/${Uri.encodeComponent(cardNumber)}",
|
||||
);
|
||||
if (data is! Map) return const RfidCardWrite();
|
||||
return RfidCardWrite.fromJson(Map<String, dynamic>.from(data));
|
||||
}
|
||||
|
||||
/// The list the charger itself holds, asked of the device rather than of the
|
||||
/// account. Both are written by every add and remove, and they can still come
|
||||
/// apart; this is the only call that says so. Answers with bare numbers,
|
||||
/// because the device has no field for a card's name.
|
||||
Future<List<String>> getAnkerChargerCards(String sn) async {
|
||||
final data =
|
||||
await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/rfid-cards/charger");
|
||||
final raw = data is Map ? data["cards"] : null;
|
||||
return raw is List ? raw.map(_asString).where((c) => c.isNotEmpty).toList() : const [];
|
||||
}
|
||||
|
||||
// Greencell (HabuDen EV charger). Same cascade, but what resolves is an MQTT
|
||||
// broker rather than a cloud account — the charger publishes to a broker the
|
||||
// owner runs and the server joins it. testGreencell connects to that broker and
|
||||
@@ -772,5 +816,44 @@ class ApiClient {
|
||||
Future<void> deleteHomeCharger(String id) =>
|
||||
_send("DELETE", "/home-chargers/${Uri.encodeComponent(id)}");
|
||||
|
||||
// --- the charging scheduler ---
|
||||
//
|
||||
// One list of charging tasks per user, covering every charger they own. The
|
||||
// server holds the clock — a schedule that only fires while the app is open
|
||||
// would be a reminder, not a schedule — so the app only writes tasks and reads
|
||||
// back how each one last went.
|
||||
|
||||
Future<List<ChargingTask>> listChargingTasks() async {
|
||||
final data = await _send("GET", "/charging-tasks");
|
||||
final items = (data is Map ? data["tasks"] : null) ?? [];
|
||||
return (items as List)
|
||||
.whereType<Map>()
|
||||
.map((e) => ChargingTask.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<ChargingTask> createChargingTask(Map<String, dynamic> body) async {
|
||||
final data = await _send("POST", "/charging-tasks", body: body);
|
||||
return ChargingTask.fromJson(Map<String, dynamic>.from((data is Map ? data["task"] : null) ?? {}));
|
||||
}
|
||||
|
||||
Future<ChargingTask> updateChargingTask(String id, Map<String, dynamic> body) async {
|
||||
final data =
|
||||
await _send("PATCH", "/charging-tasks/${Uri.encodeComponent(id)}", body: body);
|
||||
return ChargingTask.fromJson(Map<String, dynamic>.from((data is Map ? data["task"] : null) ?? {}));
|
||||
}
|
||||
|
||||
Future<void> deleteChargingTask(String id) =>
|
||||
_send("DELETE", "/charging-tasks/${Uri.encodeComponent(id)}");
|
||||
|
||||
/// One step of a flow, fired now: running a start and the stop that closes it
|
||||
/// back to back would leave the charger where it began and prove nothing.
|
||||
/// Answers with the same summary the clock's own firing would record.
|
||||
Future<String> runChargingStep(String id, int step) async {
|
||||
final data = await _send(
|
||||
"POST", "/charging-tasks/${Uri.encodeComponent(id)}/steps/$step/run");
|
||||
return data is Map ? _asString(data["summary"]) : "";
|
||||
}
|
||||
|
||||
static String _asString(dynamic v) => v == null ? "" : v.toString();
|
||||
}
|
||||
|
||||
@@ -11,12 +11,25 @@ class AppSettings extends ChangeNotifier {
|
||||
static const _kTheme = "cc_theme";
|
||||
static const _kLocale = "cc_locale";
|
||||
static const _kDateFormat = "cc_dateFormat";
|
||||
static const _kTimeFormat = "cc_timeFormat";
|
||||
static const _kWeekStart = "cc_weekStart";
|
||||
static const _kCurrency = "cc_currency";
|
||||
static const _kFontSize = "cc_fontSize";
|
||||
|
||||
String theme = "system"; // light | dark | system
|
||||
String locale = "en-US"; // BCP-47 language-REGION
|
||||
String dateFormat = "YMD"; // YMD | DMY_NUM | DMY | MDY
|
||||
|
||||
/// Which clock times are written on. "auto" is the chosen region's own
|
||||
/// convention, which is what every time in the app read before there was a
|
||||
/// setting; the other two are for the people whose region and habit disagree.
|
||||
String timeFormat = "auto"; // auto | 24 | 12
|
||||
|
||||
/// The day a week is drawn as starting on, wherever weekdays are laid out in a
|
||||
/// row — the charging scheduler's day picker today. See format.dart, which
|
||||
/// owns the rule so every such row reads the same.
|
||||
String weekStart = "auto"; // auto | monday | sunday
|
||||
|
||||
String currency = "USD"; // ISO 4217 code
|
||||
String fontSize = "medium"; // small | medium | large
|
||||
|
||||
@@ -30,6 +43,8 @@ class AppSettings extends ChangeNotifier {
|
||||
theme = prefs.getString(_kTheme) ?? theme;
|
||||
locale = prefs.getString(_kLocale) ?? locale;
|
||||
dateFormat = prefs.getString(_kDateFormat) ?? dateFormat;
|
||||
timeFormat = prefs.getString(_kTimeFormat) ?? timeFormat;
|
||||
weekStart = prefs.getString(_kWeekStart) ?? weekStart;
|
||||
currency = prefs.getString(_kCurrency) ?? currency;
|
||||
fontSize = prefs.getString(_kFontSize) ?? fontSize;
|
||||
notifyListeners();
|
||||
@@ -40,6 +55,8 @@ class AppSettings extends ChangeNotifier {
|
||||
await prefs.setString(_kTheme, theme);
|
||||
await prefs.setString(_kLocale, locale);
|
||||
await prefs.setString(_kDateFormat, dateFormat);
|
||||
await prefs.setString(_kTimeFormat, timeFormat);
|
||||
await prefs.setString(_kWeekStart, weekStart);
|
||||
await prefs.setString(_kCurrency, currency);
|
||||
await prefs.setString(_kFontSize, fontSize);
|
||||
}
|
||||
@@ -49,6 +66,8 @@ class AppSettings extends ChangeNotifier {
|
||||
theme = p.theme;
|
||||
locale = p.locale;
|
||||
dateFormat = p.dateFormat;
|
||||
timeFormat = p.timeFormat;
|
||||
weekStart = p.weekStart;
|
||||
currency = p.currency;
|
||||
fontSize = p.fontSize;
|
||||
_persist();
|
||||
@@ -61,12 +80,16 @@ class AppSettings extends ChangeNotifier {
|
||||
String? theme,
|
||||
String? locale,
|
||||
String? dateFormat,
|
||||
String? timeFormat,
|
||||
String? weekStart,
|
||||
String? currency,
|
||||
String? fontSize,
|
||||
}) {
|
||||
if (theme != null) this.theme = theme;
|
||||
if (locale != null) this.locale = locale;
|
||||
if (dateFormat != null) this.dateFormat = dateFormat;
|
||||
if (timeFormat != null) this.timeFormat = timeFormat;
|
||||
if (weekStart != null) this.weekStart = weekStart;
|
||||
if (currency != null) this.currency = currency;
|
||||
if (fontSize != null) this.fontSize = fontSize;
|
||||
_persist();
|
||||
|
||||
+143
-1
@@ -71,7 +71,149 @@ String formatPartialDate(String iso) {
|
||||
/// disagree on screen.
|
||||
String formatDateTime(DateTime? d) {
|
||||
if (d == null) return "—";
|
||||
return "${formatDate(d)} ${DateFormat.Hm(_locale).format(d)}";
|
||||
return "${formatDate(d)} ${formatTime(d)}";
|
||||
}
|
||||
|
||||
/// The clock alone. "auto" leaves the reading to the region, which is what every
|
||||
/// time in the app said before there was a setting; the other two are for the
|
||||
/// people whose region and habit disagree — plenty of Poles read 12-hour clocks
|
||||
/// and plenty of Americans read 24-hour ones, and the region picker also decides
|
||||
/// how money and numbers are grouped, so it is the wrong lever to reach for.
|
||||
///
|
||||
/// The setting decides *which* clock; this file decides how it is punctuated.
|
||||
/// A region is worth asking whether a reader expects 13:45 or 01:45 pm — that is
|
||||
/// a real difference in how people tell the time. It is not worth asking whether
|
||||
/// the two numbers are joined by a colon or a dot: Danish writes 13.45, and one
|
||||
/// screen of DriverVault writing 13.45 while the next writes 13:45 is not local
|
||||
/// colour, it is an inconsistency. So every time this app prints comes out of
|
||||
/// the two lines below, the same as the web app's format.js.
|
||||
///
|
||||
/// The cost is that the am/pm marker reads in English everywhere. It is the same
|
||||
/// trade the setting itself makes: a 12-hour clock is not a convention most of
|
||||
/// these regions use, so choosing one — or living in a region that does — is
|
||||
/// choosing the clock that comes with it.
|
||||
String formatTime(DateTime? d) {
|
||||
if (d == null) return "—";
|
||||
final mm = d.minute.toString().padLeft(2, "0");
|
||||
if (!clockIsTwelveHour()) return "${d.hour.toString().padLeft(2, "0")}:$mm";
|
||||
// 12 for both noon and midnight, and midnight is the am one.
|
||||
final h = d.hour % 12 == 0 ? 12 : d.hour % 12;
|
||||
return "${h.toString().padLeft(2, "0")}:$mm ${d.hour < 12 ? "am" : "pm"}";
|
||||
}
|
||||
|
||||
/// A wall-clock "HH:MM" — a schedule is a time of day, not a moment, so there is
|
||||
/// no date to hand [formatTime] — read on the clock the user chose. Anything
|
||||
/// that is not a time of day comes back as it arrived.
|
||||
String formatClock(String hhmm) {
|
||||
final m = RegExp(r"^(\d{1,2}):(\d{2})$").firstMatch(hhmm.trim());
|
||||
if (m == null) return hhmm;
|
||||
final h = int.parse(m.group(1)!);
|
||||
if (!clockIsTwelveHour()) return "${h.toString().padLeft(2, "0")}:${m.group(2)}";
|
||||
final twelve = h % 12 == 0 ? 12 : h % 12;
|
||||
return "${twelve.toString().padLeft(2, "0")}:${m.group(2)} ${h < 12 ? "am" : "pm"}";
|
||||
}
|
||||
|
||||
/// Whether times are written on a 12-hour clock right now: what the setting says
|
||||
/// outright, or what the region says when it is left on auto.
|
||||
///
|
||||
/// Not only [formatTime]'s business. A control that lets somebody *enter* a time
|
||||
/// has to offer the same clock, and a box that reads 13:45 beside a picker that
|
||||
/// says 01:45 PM is the disagreement this setting exists to end — see
|
||||
/// widgets/time_field.dart.
|
||||
bool clockIsTwelveHour() {
|
||||
switch (appSettings.timeFormat) {
|
||||
case "12":
|
||||
return true;
|
||||
case "24":
|
||||
return false;
|
||||
default:
|
||||
return _regionReadsTwelveHour();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the chosen region tells the time on a 12-hour clock — the one
|
||||
/// question "auto" asks it. Cached because this is asked once per timestamp on a
|
||||
/// page that can hold a great many, and the answer only changes with the region.
|
||||
final Map<String, bool> _twelveHourRegions = {};
|
||||
|
||||
bool _regionReadsTwelveHour() {
|
||||
return _twelveHourRegions.putIfAbsent(_locale, () {
|
||||
try {
|
||||
// DateFormat.j() is the locale's own preferred hour field: "h" where it is
|
||||
// read on a 12-hour clock, "H" where it is not.
|
||||
return DateFormat.j(_locale).pattern?.contains("h") ?? false;
|
||||
} catch (_) {
|
||||
// An unusable locale is not a reason to print nothing; 24-hour is the
|
||||
// safer default, being the one that cannot be read as the wrong half of
|
||||
// the day.
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Weekdays ---------------------------------------------------------------
|
||||
//
|
||||
// A week does not start on the same day everywhere: Monday across most of
|
||||
// Europe, Sunday in the US and a good deal of Asia. A row of weekday buttons
|
||||
// that always begins on Sunday reads wrong to half the people looking at it,
|
||||
// and reads wrong in a way that is easy to mistap — Settings › Appearance ›
|
||||
// First day of the week is the answer, with "auto" following the chosen region
|
||||
// the way the clock setting does.
|
||||
//
|
||||
// Everything that lays weekdays out in a row goes through these, so there is one
|
||||
// answer to "which day comes first" rather than one per screen. Days are
|
||||
// numbered the way the scheduler's stored tasks number them: 0 = Sunday …
|
||||
// 6 = Saturday.
|
||||
|
||||
/// Whether weeks are drawn as starting on Monday right now: what the setting
|
||||
/// says outright, or what the region says when it is left on auto.
|
||||
bool weekStartsOnMonday() {
|
||||
switch (appSettings.weekStart) {
|
||||
case "monday":
|
||||
return true;
|
||||
case "sunday":
|
||||
return false;
|
||||
default:
|
||||
return _regionStartsOnMonday();
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, bool> _mondayRegions = {};
|
||||
|
||||
bool _regionStartsOnMonday() {
|
||||
return _mondayRegions.putIfAbsent(_locale, () {
|
||||
try {
|
||||
// intl carries the region's own answer in its date symbols, numbered
|
||||
// 0 = Monday … 6 = Sunday (the Closure convention its data came from).
|
||||
return DateFormat.yMd(_locale).dateSymbols.FIRSTDAYOFWEEK == 0;
|
||||
} catch (_) {
|
||||
// Monday is the safer default: it is ISO 8601's, and the convention in
|
||||
// every region this app's own currency list covers bar one.
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The seven days in the order they should be drawn, as day numbers.
|
||||
List<int> weekdaysInOrder() =>
|
||||
weekStartsOnMonday() ? const [1, 2, 3, 4, 5, 6, 0] : const [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
/// One day's short name in the user's own language, so a row reads Pn Wt Śr in
|
||||
/// Polish without a table here. 2024-01-07 was a Sunday, which is where day 0
|
||||
/// sits, so the offset lands each number on its own day.
|
||||
String weekdayShortName(int day) {
|
||||
try {
|
||||
return DateFormat.E(_locale).format(DateTime.utc(2024, 1, 7 + day));
|
||||
} catch (_) {
|
||||
return "$day";
|
||||
}
|
||||
}
|
||||
|
||||
/// A set of days, listed in the order this account reads a week in — so the same
|
||||
/// three days always come out in the same order wherever they are shown.
|
||||
List<int> sortWeekdays(Iterable<int> days) {
|
||||
final order = weekdaysInOrder();
|
||||
return days.toList()..sort((a, b) => order.indexOf(a).compareTo(order.indexOf(b)));
|
||||
}
|
||||
|
||||
// 0 km is a reading — a car collected new — not a blank. See format.js.
|
||||
|
||||
@@ -890,6 +890,12 @@ class UserProfile {
|
||||
final String theme; // light | dark | system
|
||||
final String locale; // BCP-47 language-REGION, e.g. "en-US"
|
||||
final String dateFormat; // YMD | DMY_NUM | DMY | MDY
|
||||
final String timeFormat; // auto (the region's own) | 24 | 12
|
||||
|
||||
/// The day a week is drawn as starting on, wherever weekdays are laid out in
|
||||
/// a row — the charging scheduler's day picker today.
|
||||
final String weekStart; // auto (the region's own) | monday | sunday
|
||||
|
||||
final String currency; // ISO 4217 code, e.g. "EUR"
|
||||
final String fontSize; // small | medium | large
|
||||
final String role; // user | admin
|
||||
@@ -914,6 +920,8 @@ class UserProfile {
|
||||
required this.theme,
|
||||
required this.locale,
|
||||
required this.dateFormat,
|
||||
this.timeFormat = "auto",
|
||||
this.weekStart = "auto",
|
||||
this.currency = "USD",
|
||||
required this.fontSize,
|
||||
required this.role,
|
||||
@@ -934,6 +942,8 @@ class UserProfile {
|
||||
theme: j["theme"] == null ? "system" : _asStr(j["theme"]),
|
||||
locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]),
|
||||
dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]),
|
||||
timeFormat: j["timeFormat"] == null ? "auto" : _asStr(j["timeFormat"]),
|
||||
weekStart: j["weekStart"] == null ? "auto" : _asStr(j["weekStart"]),
|
||||
currency: j["currency"] == null ? "USD" : _asStr(j["currency"]),
|
||||
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
|
||||
role: j["role"] == null ? "user" : _asStr(j["role"]),
|
||||
@@ -1235,6 +1245,10 @@ class AnkerCharger {
|
||||
final String statusDesc; // charging | standby | … as the cloud names it
|
||||
final bool? online; // null when no view reported a connection state
|
||||
|
||||
/// The product shot the service holds for this model, when it sent one. A URL
|
||||
/// rather than an image: it is fetched only where it is drawn.
|
||||
final String imageUrl;
|
||||
|
||||
const AnkerCharger({
|
||||
required this.sn,
|
||||
this.name = "",
|
||||
@@ -1243,6 +1257,7 @@ class AnkerCharger {
|
||||
this.siteName = "",
|
||||
this.statusDesc = "",
|
||||
this.online,
|
||||
this.imageUrl = "",
|
||||
});
|
||||
|
||||
factory AnkerCharger.fromJson(Map<String, dynamic> j) => AnkerCharger(
|
||||
@@ -1253,6 +1268,7 @@ class AnkerCharger {
|
||||
siteName: _asStr(j["siteName"]),
|
||||
statusDesc: _asStr(j["statusDesc"]),
|
||||
online: j["online"] is bool ? j["online"] as bool : null,
|
||||
imageUrl: _asStr(j["imageUrl"]),
|
||||
);
|
||||
|
||||
/// What to call the charger in a list: its name when it has one, its serial
|
||||
@@ -1284,6 +1300,180 @@ class AnkerChargerList {
|
||||
}
|
||||
}
|
||||
|
||||
// --- RFID cards (who may start a charge without a phone) --------------------
|
||||
|
||||
/// One card authorised on a charger, as the account holds it. The cloud answers
|
||||
/// with its own field names — alias_name, card_number, create_time — and this is
|
||||
/// the same three read out: a number to delete by, the name it was given, and
|
||||
/// when it was added.
|
||||
class RfidCard {
|
||||
final String number;
|
||||
final String name;
|
||||
final String added; // as the cloud sent it: unix seconds, or ""
|
||||
|
||||
const RfidCard({required this.number, this.name = "", this.added = ""});
|
||||
|
||||
factory RfidCard.fromJson(Map<String, dynamic> j) {
|
||||
final number = _asStr(j["card_number"]).trim();
|
||||
final name = _asStr(j["alias_name"]).trim();
|
||||
return RfidCard(
|
||||
number: number,
|
||||
// A card with no name of its own is still a card somebody holds, and its
|
||||
// number is the only honest thing to call it.
|
||||
name: name.isEmpty ? number : name,
|
||||
added: _asStr(j["create_time"]).trim(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a card write answers with: whether the account holds that card now, and
|
||||
/// the whole list as it stands after the write. Anker documents neither endpoint,
|
||||
/// so a 200 proves nothing on its own — it is the list that says what happened.
|
||||
class RfidCardWrite {
|
||||
final bool present;
|
||||
final List<RfidCard> cards;
|
||||
final String detail;
|
||||
|
||||
const RfidCardWrite({this.present = false, this.cards = const [], this.detail = ""});
|
||||
|
||||
factory RfidCardWrite.fromJson(Map<String, dynamic> j) => RfidCardWrite(
|
||||
present: _asBool(j["present"]),
|
||||
cards: j["cards"] is List
|
||||
? (j["cards"] as List)
|
||||
.whereType<Map>()
|
||||
.map((c) => RfidCard.fromJson(Map<String, dynamic>.from(c)))
|
||||
.where((c) => c.number.isNotEmpty)
|
||||
.toList()
|
||||
: const [],
|
||||
detail: _asStr(j["detail"]),
|
||||
);
|
||||
}
|
||||
|
||||
/// What a scan answers with: the card that was held against the reader, or the
|
||||
/// plain fact that nothing was. A window that closed empty is an answer, not a
|
||||
/// timeout, which is why [tapped] is separate from [card].
|
||||
class RfidScan {
|
||||
final bool tapped;
|
||||
final String card;
|
||||
|
||||
const RfidScan({this.tapped = false, this.card = ""});
|
||||
|
||||
factory RfidScan.fromJson(Map<String, dynamic> j) =>
|
||||
RfidScan(tapped: _asBool(j["tapped"]), card: _asStr(j["card"]).trim());
|
||||
}
|
||||
|
||||
// --- the charging scheduler -------------------------------------------------
|
||||
//
|
||||
// The charger's own cloud schedule can say one thing — "charge between these
|
||||
// hours" — and it says it inside one charger. This is a list, and each entry is
|
||||
// a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30, on these
|
||||
// chargers, on these days. One named thing, switched on and off as one.
|
||||
|
||||
/// One command in a task's flow: what to do, and at what time of day.
|
||||
class ChargingStep {
|
||||
/// start, stop, limit (to [amps]) or boost.
|
||||
final String action;
|
||||
final double amps;
|
||||
|
||||
/// A 24-hour "HH:MM", read in the task's zone.
|
||||
final String time;
|
||||
|
||||
const ChargingStep({required this.action, required this.time, this.amps = 0});
|
||||
|
||||
factory ChargingStep.fromJson(Map<String, dynamic> j) => ChargingStep(
|
||||
action: _asStr(j["action"]),
|
||||
time: _asStr(j["time"]),
|
||||
amps: _asDouble(j["amps"]),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {"action": action, "time": time, "amps": amps};
|
||||
}
|
||||
|
||||
/// One entry in the home-charger scheduler. It belongs to the person, like the
|
||||
/// chargers it acts on — one list covering every charger they own, rather than a
|
||||
/// separate schedule inside each one.
|
||||
class ChargingTask {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// The home-charger records this task acts on. Empty means every charger the
|
||||
/// owner has, including ones imported after the task was written — "all of
|
||||
/// them" is a standing wish, not the list that happened to exist that day.
|
||||
final List<String> chargers;
|
||||
|
||||
/// The flow, in the order it runs.
|
||||
final List<ChargingStep> steps;
|
||||
|
||||
/// The IANA zone the steps' times are read in. The server's own clock is not
|
||||
/// the one the user set 23:00 by.
|
||||
final String zone;
|
||||
|
||||
/// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day.
|
||||
final List<int> days;
|
||||
|
||||
final bool enabled;
|
||||
|
||||
/// What happened the last time a step of it fired, so a task that has been
|
||||
/// failing quietly for a week says so in the list rather than in a log nobody
|
||||
/// reads.
|
||||
final DateTime? lastRun;
|
||||
final String lastResult;
|
||||
|
||||
const ChargingTask({
|
||||
required this.id,
|
||||
this.name = "",
|
||||
this.chargers = const [],
|
||||
this.steps = const [],
|
||||
this.zone = "",
|
||||
this.days = const [],
|
||||
this.enabled = true,
|
||||
this.lastRun,
|
||||
this.lastResult = "",
|
||||
});
|
||||
|
||||
factory ChargingTask.fromJson(Map<String, dynamic> j) => ChargingTask(
|
||||
id: _asStr(j["id"]),
|
||||
name: _asStr(j["name"]),
|
||||
chargers: _asStrList(j["chargers"]),
|
||||
steps: j["steps"] is List
|
||||
? (j["steps"] as List)
|
||||
.whereType<Map>()
|
||||
.map((e) => ChargingStep.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList()
|
||||
: const [],
|
||||
zone: _asStr(j["zone"]),
|
||||
days: j["days"] is List ? (j["days"] as List).map(_asInt).toList() : const [],
|
||||
enabled: _asBool(j["enabled"]),
|
||||
lastRun: j["lastRun"] == null ? null : DateTime.tryParse(_asStr(j["lastRun"]))?.toLocal(),
|
||||
lastResult: _asStr(j["lastResult"]),
|
||||
);
|
||||
|
||||
/// The time of day the task begins — its first step's, which is what the list
|
||||
/// is ordered by, so the evening's task sits below the morning's.
|
||||
String get firstTime => steps.isEmpty ? "99:99" : steps.first.time;
|
||||
|
||||
/// Whether the last firing reached every charger it was aimed at. The server
|
||||
/// words the outcome as the step it fired and then "n of m sent", so every
|
||||
/// charger answering is the only good case; unknown until it has fired once.
|
||||
bool? get lastRunOk {
|
||||
if (lastRun == null) return null;
|
||||
final m = RegExp(r"(\d+) of (\d+) sent$").firstMatch(lastResult);
|
||||
return m != null && m.group(1) == m.group(2);
|
||||
}
|
||||
|
||||
ChargingTask copyWith({bool? enabled, DateTime? lastRun, String? lastResult}) => ChargingTask(
|
||||
id: id,
|
||||
name: name,
|
||||
chargers: chargers,
|
||||
steps: steps,
|
||||
zone: zone,
|
||||
days: days,
|
||||
enabled: enabled ?? this.enabled,
|
||||
lastRun: lastRun ?? this.lastRun,
|
||||
lastResult: lastResult ?? this.lastResult,
|
||||
);
|
||||
}
|
||||
|
||||
// --- home chargers (the user's own wallbox) ---------------------------------
|
||||
//
|
||||
// The garage's import, aimed at the wall: a charger on a connected service
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "../format.dart";
|
||||
import "../i18n.dart";
|
||||
import "../main.dart";
|
||||
import "../models.dart";
|
||||
import "../theme.dart";
|
||||
import "../widgets/time_field.dart";
|
||||
|
||||
/// One line of the home-charger scheduler, being written or edited.
|
||||
///
|
||||
/// The charger's own cloud schedule asks four questions and asks them inside one
|
||||
/// charger: on/off, mode, from, to. This asks five, and the fifth is the one that
|
||||
/// makes it a scheduler rather than a second copy of that: *which* chargers. A
|
||||
/// task can name one, several, or none at all — and none means every charger on
|
||||
/// the account, including ones imported after the task was written, because "all
|
||||
/// of them" is a standing wish rather than the list that happened to exist that
|
||||
/// day.
|
||||
///
|
||||
/// A task holds a flow rather than a single command: start at 23:00, ease down
|
||||
/// to 10 A at 01:00, stop at 06:30. That is one intention, so it is one named
|
||||
/// thing with one switch — splitting a charging window across two tasks meant
|
||||
/// naming it twice and remembering to switch off both ends.
|
||||
///
|
||||
/// Pops the saved [ChargingTask].
|
||||
Future<ChargingTask?> showChargingTaskSheet(
|
||||
BuildContext context, {
|
||||
ChargingTask? task,
|
||||
required List<HomeCharger> chargers,
|
||||
}) {
|
||||
return showModalBottomSheet<ChargingTask>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _ChargingTaskSheet(task: task, chargers: chargers),
|
||||
);
|
||||
}
|
||||
|
||||
/// What the charger can actually be asked to do. Boost and the current limit
|
||||
/// only reach it over the Anker cloud connection; start and stop reach it over
|
||||
/// all three transports. The control mode is a Settings choice, not this form's
|
||||
/// business, so all four are offered and the one that cannot be sent says so
|
||||
/// when it fires — same as the buttons on the page behind this.
|
||||
const List<String> _kActions = ["start", "stop", "limit", "boost"];
|
||||
|
||||
/// One row of the flow while it is being edited. Mutable, because the form edits
|
||||
/// the rows in place; [ChargingStep] is what gets sent.
|
||||
class _StepDraft {
|
||||
String action;
|
||||
String time;
|
||||
double amps;
|
||||
_StepDraft(this.action, this.time, this.amps);
|
||||
}
|
||||
|
||||
class _ChargingTaskSheet extends StatefulWidget {
|
||||
final ChargingTask? task;
|
||||
final List<HomeCharger> chargers;
|
||||
const _ChargingTaskSheet({this.task, required this.chargers});
|
||||
@override
|
||||
State<_ChargingTaskSheet> createState() => _ChargingTaskSheetState();
|
||||
}
|
||||
|
||||
class _ChargingTaskSheetState extends State<_ChargingTaskSheet> {
|
||||
late final TextEditingController _name =
|
||||
TextEditingController(text: widget.task?.name ?? "");
|
||||
|
||||
/// The flow, as rows the form edits in place. A new task opens with the one
|
||||
/// step most schedules start from, so the common case is a name and a time
|
||||
/// rather than a decision about how many rows to add.
|
||||
late final List<_StepDraft> _steps = (widget.task?.steps ?? const []).isEmpty
|
||||
? [_StepDraft("start", "23:00", 16)]
|
||||
: widget.task!.steps
|
||||
.map((s) => _StepDraft(s.action, s.time, s.amps > 0 ? s.amps : 16))
|
||||
.toList();
|
||||
|
||||
/// The chargers this task acts on. Empty is meaningful — it means all of them
|
||||
/// — so the picker has a switch of its own rather than leaving an empty list
|
||||
/// looking like an unfinished form.
|
||||
late bool _allChargers = widget.task == null || widget.task!.chargers.isEmpty;
|
||||
late final Set<String> _picked = {...?widget.task?.chargers};
|
||||
late bool _everyDay = widget.task == null || widget.task!.days.isEmpty;
|
||||
late final Set<int> _days = {...?widget.task?.days};
|
||||
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
bool get _editing => (widget.task?.id ?? "").isNotEmpty;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// A flow of one is a flow, so the last row cannot be removed — an empty task
|
||||
/// would have nothing to fire and the server refuses it anyway.
|
||||
void _addStep() => setState(() => _steps.add(_StepDraft("stop", "06:30", 16)));
|
||||
|
||||
void _removeStep(int i) {
|
||||
if (_steps.length <= 1) return;
|
||||
setState(() => _steps.removeAt(i));
|
||||
}
|
||||
|
||||
/// Unticking every day (or every charger) by hand is the same wish as the
|
||||
/// "all" switch, so it lands there rather than leaving a task that acts on
|
||||
/// nothing.
|
||||
void _toggleDay(int day) {
|
||||
setState(() {
|
||||
_everyDay = false;
|
||||
_days.contains(day) ? _days.remove(day) : _days.add(day);
|
||||
if (_days.isEmpty) _everyDay = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleCharger(String id) {
|
||||
setState(() {
|
||||
_allChargers = false;
|
||||
_picked.contains(id) ? _picked.remove(id) : _picked.add(id);
|
||||
if (_picked.isEmpty) _allChargers = true;
|
||||
});
|
||||
}
|
||||
|
||||
/// A time still being typed is not a time — [TimeField] says so with an empty
|
||||
/// value — and one unfinished row is enough to make the whole flow unsaveable,
|
||||
/// because the server would otherwise refuse it with a step number the form
|
||||
/// does not show.
|
||||
bool get _canSave =>
|
||||
_name.text.trim().isNotEmpty && _steps.every((s) => s.time.isNotEmpty) && !_saving;
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_canSave) return;
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
final body = <String, dynamic>{
|
||||
"name": _name.text.trim(),
|
||||
// The amps ride along on every step so switching one to "limit" and back
|
||||
// does not lose the number that was typed; the server keeps them for the
|
||||
// same reason and ignores them on the actions that have no ceiling.
|
||||
"steps": [
|
||||
for (final s in _steps)
|
||||
{
|
||||
"action": s.action,
|
||||
"time": s.time,
|
||||
"amps": s.action == "limit" ? s.amps : 0,
|
||||
},
|
||||
],
|
||||
"chargers": _allChargers ? <String>[] : _picked.toList(),
|
||||
"days": _everyDay ? <int>[] : _days.toList(),
|
||||
// The time is a wall clock, and the server's is not the one it was set by.
|
||||
// Sending the zone this phone is in is what keeps 23:00 at 23:00 for a
|
||||
// server sitting in another country.
|
||||
"zone": _deviceZone(),
|
||||
};
|
||||
try {
|
||||
final saved = _editing
|
||||
? await apiClient.updateChargingTask(widget.task!.id, body)
|
||||
: await apiClient.createChargingTask(body);
|
||||
if (mounted) Navigator.pop(context, saved);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = "$e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The phone's own zone name. Dart has no IANA name to hand — only an offset
|
||||
/// and the platform's abbreviation — so the server is sent what it can read
|
||||
/// and falls back to its own clock when it cannot: an offset is not a zone,
|
||||
/// and a name that is not IANA would be worse than saying nothing.
|
||||
String _deviceZone() {
|
||||
final name = DateTime.now().timeZoneName;
|
||||
return name.contains("/") ? name : "";
|
||||
}
|
||||
|
||||
String _chargerSubtitle(HomeCharger c) =>
|
||||
[c.serial, c.model].where((v) => v.isNotEmpty).join(" · ");
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = DriverVault.muted(context);
|
||||
final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: DriverVault.sheetBottomInset(context),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t(_editing ? "forms.chargingTask.editTitle" : "forms.chargingTask.title"),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
|
||||
Text(t("forms.chargingTask.name"),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _name,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
hintText: t("forms.chargingTask.namePlaceholder"),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
|
||||
// The flow. One row per step, each an action and the time it fires —
|
||||
// read down, they are the night: start at 23:00, ease off at 01:00,
|
||||
// stop at 06:30.
|
||||
const SizedBox(height: 16),
|
||||
Text(t("forms.chargingTask.flow"),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
for (var i = 0; i < _steps.length; i++) _stepRow(context, i, sunken, muted),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: _addStep,
|
||||
child: Text(t("forms.chargingTask.addStep")),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(t("forms.chargingTask.flowHint"),
|
||||
style: TextStyle(fontSize: 12, color: muted)),
|
||||
),
|
||||
|
||||
// Which chargers. The point of one scheduler for all of them.
|
||||
const SizedBox(height: 16),
|
||||
Text(t("forms.chargingTask.chargers"),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
CheckboxListTile(
|
||||
value: _allChargers,
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
title: Text(t("forms.chargingTask.allChargers"),
|
||||
style: const TextStyle(fontSize: 14)),
|
||||
subtitle: _allChargers
|
||||
? Text(t("forms.chargingTask.allChargersHint"),
|
||||
style: TextStyle(fontSize: 12, color: muted))
|
||||
: null,
|
||||
onChanged: (on) => setState(() {
|
||||
_allChargers = on ?? true;
|
||||
if (_allChargers) _picked.clear();
|
||||
}),
|
||||
),
|
||||
if (widget.chargers.isEmpty)
|
||||
Text(t("forms.chargingTask.noChargers"),
|
||||
style: TextStyle(fontSize: 12, color: muted)),
|
||||
for (final c in widget.chargers)
|
||||
CheckboxListTile(
|
||||
value: !_allChargers && _picked.contains(c.id),
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
title: Text(c.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14)),
|
||||
subtitle: _chargerSubtitle(c).isEmpty
|
||||
? null
|
||||
: Text(_chargerSubtitle(c),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DriverVault.mono(context, size: 11, color: muted)),
|
||||
onChanged: (_) => _toggleCharger(c.id),
|
||||
),
|
||||
|
||||
// Which days.
|
||||
const SizedBox(height: 8),
|
||||
Text(t("forms.chargingTask.days"),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
CheckboxListTile(
|
||||
value: _everyDay,
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
title: Text(t("forms.chargingTask.everyDay"),
|
||||
style: const TextStyle(fontSize: 14)),
|
||||
onChanged: (on) => setState(() {
|
||||
_everyDay = on ?? true;
|
||||
if (_everyDay) _days.clear();
|
||||
}),
|
||||
),
|
||||
// The row starts on whichever day this account reads a week as
|
||||
// starting on — Settings › Appearance › First day of the week,
|
||||
// following the region unless it was answered outright. format.dart
|
||||
// owns the rule for every weekday row in the app.
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final d in weekdaysInOrder())
|
||||
ChoiceChip(
|
||||
label: Text(weekdayShortName(d), style: const TextStyle(fontSize: 12)),
|
||||
selected: !_everyDay && _days.contains(d),
|
||||
onSelected: (_) => _toggleDay(d),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t("common.cancel")),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _canSave ? _submit : null,
|
||||
child: Text(_saving ? t("common.saving") : t("common.save")),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepRow(BuildContext context, int i, Color sunken, Color muted) {
|
||||
final s = _steps[i];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: sunken,
|
||||
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: s.action,
|
||||
isExpanded: true,
|
||||
decoration:
|
||||
const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||
items: [
|
||||
for (final a in _kActions)
|
||||
DropdownMenuItem(
|
||||
value: a,
|
||||
child: Text(t("charging.scheduler.actions.$a"),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() => s.action = v ?? s.action),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TimeField(
|
||||
value: s.time,
|
||||
onChanged: (v) => setState(() => s.time = v),
|
||||
),
|
||||
// The last step cannot go: a task with no steps has nothing to
|
||||
// fire, so the control is absent rather than there and refusing.
|
||||
if (_steps.length > 1)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
color: muted,
|
||||
tooltip: t("forms.chargingTask.removeStep"),
|
||||
onPressed: () => _removeStep(i),
|
||||
),
|
||||
],
|
||||
),
|
||||
// The ceiling, under the one action that takes one.
|
||||
if (s.action == "limit") ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
Text(t("forms.chargingTask.amps"),
|
||||
style: TextStyle(fontSize: 13, color: muted)),
|
||||
const Spacer(),
|
||||
Text("${s.amps.round()} A",
|
||||
style: DriverVault.mono(context, size: 13, weight: FontWeight.w600)),
|
||||
]),
|
||||
Slider(
|
||||
value: s.amps.clamp(6, 32),
|
||||
min: 6,
|
||||
max: 32,
|
||||
divisions: 26,
|
||||
label: "${s.amps.round()} A",
|
||||
onChanged: (v) => setState(() => s.amps = v),
|
||||
),
|
||||
Text(t("forms.chargingTask.ampsHint"),
|
||||
style: TextStyle(fontSize: 11, color: muted)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -257,7 +257,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
TextFormField(
|
||||
controller: _email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: InputDecoration(labelText: t("login.email"), border: const OutlineInputBorder()),
|
||||
decoration: InputDecoration(
|
||||
labelText: t("login.email"),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: "you@example.com",
|
||||
),
|
||||
validator: (v) => (v == null || v.isEmpty) ? t("common.required") : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -267,6 +271,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
decoration: InputDecoration(
|
||||
labelText: t("login.password"),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: "••••••••",
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
|
||||
tooltip: _showPassword ? t("login.hidePassword") : t("login.showPassword"),
|
||||
|
||||
@@ -480,6 +480,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
||||
"theme": appSettings.theme,
|
||||
"locale": appSettings.locale,
|
||||
"dateFormat": appSettings.dateFormat,
|
||||
"timeFormat": appSettings.timeFormat,
|
||||
"weekStart": appSettings.weekStart,
|
||||
"currency": appSettings.currency,
|
||||
"fontSize": appSettings.fontSize,
|
||||
};
|
||||
@@ -487,6 +489,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
||||
theme: patch["theme"],
|
||||
locale: patch["locale"],
|
||||
dateFormat: patch["dateFormat"],
|
||||
timeFormat: patch["timeFormat"],
|
||||
weekStart: patch["weekStart"],
|
||||
currency: patch["currency"],
|
||||
fontSize: patch["fontSize"],
|
||||
);
|
||||
@@ -498,6 +502,8 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
||||
theme: prev["theme"],
|
||||
locale: prev["locale"],
|
||||
dateFormat: prev["dateFormat"],
|
||||
timeFormat: prev["timeFormat"],
|
||||
weekStart: prev["weekStart"],
|
||||
currency: prev["currency"],
|
||||
fontSize: prev["fontSize"],
|
||||
);
|
||||
@@ -615,6 +621,63 @@ class _AppearanceSectionState extends State<_AppearanceSection> {
|
||||
child: Text(t("settings.appearance.dateHint", params: {"example": formatDate(DateTime.now())}),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
),
|
||||
|
||||
// Beside the date rather than under the region, because it is the same
|
||||
// question asked about the other half of a timestamp.
|
||||
const SizedBox(height: 16),
|
||||
Text(t("settings.appearance.timeFormat"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: appSettings.timeFormat,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||
items: [
|
||||
DropdownMenuItem(value: "auto", child: Text(t("settings.appearance.timeAuto"))),
|
||||
DropdownMenuItem(value: "24", child: Text(t("settings.appearance.time24"))),
|
||||
DropdownMenuItem(value: "12", child: Text(t("settings.appearance.time12"))),
|
||||
],
|
||||
onChanged: (v) => v == null ? null : _save({"timeFormat": v}),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
// Thirteen-something rather than now: an example at 09:00 reads the
|
||||
// same in both conventions, which is the one time of day that cannot
|
||||
// show the choice.
|
||||
child: Text(
|
||||
t("settings.appearance.timeExample", params: {
|
||||
"example": formatTime(DateTime(2024, 1, 1, 13, 45)),
|
||||
}),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
// Under the clock, as the last of the three questions a region is asked
|
||||
// and the one it is least often asked out loud.
|
||||
const SizedBox(height: 16),
|
||||
Text(t("settings.appearance.weekStart"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: appSettings.weekStart,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||
items: [
|
||||
DropdownMenuItem(value: "auto", child: Text(t("settings.appearance.weekAuto"))),
|
||||
DropdownMenuItem(value: "monday", child: Text(t("settings.appearance.weekMonday"))),
|
||||
DropdownMenuItem(value: "sunday", child: Text(t("settings.appearance.weekSunday"))),
|
||||
],
|
||||
onChanged: (v) => v == null ? null : _save({"weekStart": v}),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
// The week as this account will now see it drawn — the clearest
|
||||
// possible example, because the setting has no other visible effect on
|
||||
// this page.
|
||||
child: Text(
|
||||
t("settings.appearance.weekExample", params: {
|
||||
"example": weekdaysInOrder().map(weekdayShortName).join(" "),
|
||||
}),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
Text(t("settings.appearance.fontSize"), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 6),
|
||||
@@ -2100,6 +2163,20 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
final showDots = isPassword
|
||||
? field.effective.isNotEmpty
|
||||
: locked && !f.showEffectiveWhenLocked;
|
||||
// What an inherited field shows when it is empty.
|
||||
//
|
||||
// A locked field is standing in for a value set above the caller, and the
|
||||
// server has already decided which of those may be read: it sends the
|
||||
// secrets back as dots and everything else in the clear. So the
|
||||
// placeholder is that effective value — the thing the field will actually
|
||||
// use — and the example is for the other case, an empty box waiting to be
|
||||
// filled in.
|
||||
//
|
||||
// The example belongs only there. A country field placeholdered "DE" under
|
||||
// the words "inherited from your organization" is not a hint, it is a
|
||||
// wrong answer to the question the user is asking it: which country am I
|
||||
// inheriting?
|
||||
final placeholder = field.locked ? field.effective : f.placeholder;
|
||||
input = TextField(
|
||||
controller: _controllers[f.key],
|
||||
obscureText: isPassword,
|
||||
@@ -2111,7 +2188,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
counterText: "",
|
||||
hintText: showDots ? "••••••••" : f.placeholder,
|
||||
hintText: showDots ? "••••••••" : placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
|
||||
import "../format.dart";
|
||||
|
||||
/// A time box that reads on the clock the user chose.
|
||||
///
|
||||
/// The same problem the web app's `components/TimeField.vue` solves, and the
|
||||
/// same shape of answer. Flutter's own `showTimePicker` renders on the *device's*
|
||||
/// locale, which nothing in this app steers: a Settings → Time format of 24-hour
|
||||
/// still met the account with an AM/PM dial, disagreeing with the 00:00 the card
|
||||
/// beside it printed. So the typing is ours — four digits, masked into the clock
|
||||
/// in force, with the meridiem as its own control rather than something to be
|
||||
/// spelled.
|
||||
///
|
||||
/// The value in and out is always 24-hour "HH:MM", which is what the charger's
|
||||
/// schedule commands take and what every caller already had. A half-typed time
|
||||
/// emits "" — a half-typed time is not a time, and emitting the part of it that
|
||||
/// parses would set the charger's schedule to whatever was passed through on the
|
||||
/// way to the value somebody meant.
|
||||
class TimeField extends StatefulWidget {
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
final bool enabled;
|
||||
final String? label;
|
||||
|
||||
const TimeField({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.enabled = true,
|
||||
this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TimeField> createState() => _TimeFieldState();
|
||||
}
|
||||
|
||||
class _TimeFieldState extends State<TimeField> {
|
||||
final _controller = TextEditingController();
|
||||
bool _pm = false;
|
||||
bool _twelve = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_twelve = clockIsTwelveHour();
|
||||
_controller.text = _toText(widget.value);
|
||||
_pm = _toPm(widget.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TimeField old) {
|
||||
super.didUpdateWidget(old);
|
||||
// Switching the setting elsewhere re-lays out what is already in the box,
|
||||
// rather than leaving one field on the old clock.
|
||||
final twelve = clockIsTwelveHour();
|
||||
if (twelve != _twelve) {
|
||||
_twelve = twelve;
|
||||
_controller.text = _toText(widget.value);
|
||||
_pm = _toPm(widget.value);
|
||||
return;
|
||||
}
|
||||
// Only re-render the box when the value it is showing is genuinely a
|
||||
// different time. Half-typed input emits "" — there is no time yet — and
|
||||
// reacting to that would wipe the very digits being typed.
|
||||
if (_toValue(_controller.text, _pm) == widget.value) return;
|
||||
_controller.text = _toText(widget.value);
|
||||
_pm = _toPm(widget.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static String _pad(int n) => n.toString().padLeft(2, "0");
|
||||
|
||||
/// "HH:MM" → its two numbers, or null for anything that is not a time of day.
|
||||
static (int, int)? _parse(String value) {
|
||||
final m = RegExp(r"^(\d{1,2}):(\d{2})$").firstMatch(value.trim());
|
||||
if (m == null) return null;
|
||||
final h = int.parse(m.group(1)!);
|
||||
final min = int.parse(m.group(2)!);
|
||||
return h > 23 || min > 59 ? null : (h, min);
|
||||
}
|
||||
|
||||
/// The digits the box shows: the hour as this clock writes it, and the minute.
|
||||
String _toText(String value) {
|
||||
final p = _parse(value);
|
||||
if (p == null) return "";
|
||||
final h = _twelve ? (p.$1 % 12 == 0 ? 12 : p.$1 % 12) : p.$1;
|
||||
return "${_pad(h)}:${_pad(p.$2)}";
|
||||
}
|
||||
|
||||
/// Whether the value sits in the afternoon. Only consulted on a 12-hour clock,
|
||||
/// where the box cannot say it and the toggle has to.
|
||||
bool _toPm(String value) {
|
||||
final p = _parse(value);
|
||||
return p != null && p.$1 >= 12;
|
||||
}
|
||||
|
||||
/// What the box and the toggle hold → "HH:MM", or "" while it is still half
|
||||
/// typed.
|
||||
String _toValue(String text, bool pm) {
|
||||
final digits = text.replaceAll(RegExp(r"\D"), "");
|
||||
if (digits.length != 4) return "";
|
||||
var h = int.parse(digits.substring(0, 2));
|
||||
final min = int.parse(digits.substring(2));
|
||||
if (min > 59) return "";
|
||||
if (_twelve) {
|
||||
if (h < 1 || h > 12) return "";
|
||||
h = (h % 12) + (pm ? 12 : 0);
|
||||
} else if (h > 23) {
|
||||
return "";
|
||||
}
|
||||
return "${_pad(h)}:${_pad(min)}";
|
||||
}
|
||||
|
||||
void _onChanged(String raw) {
|
||||
// The formatter below has already regrouped the digits; this only reports
|
||||
// what they now mean.
|
||||
widget.onChanged(_toValue(raw, _pm));
|
||||
}
|
||||
|
||||
void _setPm(bool pm) {
|
||||
setState(() => _pm = pm);
|
||||
widget.onChanged(_toValue(_controller.text, pm));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 76,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
enabled: widget.enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
inputFormatters: [_ClockMask()],
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
counterText: "",
|
||||
hintText: "--:--",
|
||||
labelText: widget.label,
|
||||
),
|
||||
onChanged: _onChanged,
|
||||
),
|
||||
),
|
||||
if (_twelve) ...[
|
||||
const SizedBox(width: 6),
|
||||
// A toggle rather than a dropdown: two values, and the one not chosen
|
||||
// is the only other answer there is.
|
||||
SegmentedButton<bool>(
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity(horizontal: -3, vertical: -3),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: false, label: Text("am")),
|
||||
ButtonSegment(value: true, label: Text("pm")),
|
||||
],
|
||||
selected: {_pm},
|
||||
onSelectionChanged: widget.enabled ? (s) => _setPm(s.first) : null,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Digits regrouped as hh:mm as they are typed. No trailing colon: it appears
|
||||
/// with the next digit, and adding it early only gives backspace something to
|
||||
/// fight with.
|
||||
class _ClockMask extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue _, TextEditingValue next) {
|
||||
var digits = next.text.replaceAll(RegExp(r"\D"), "");
|
||||
if (digits.length > 4) digits = digits.substring(0, 4);
|
||||
final text =
|
||||
digits.length > 2 ? "${digits.substring(0, 2)}:${digits.substring(2)}" : digits;
|
||||
return TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: text.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,8 @@ void main() {
|
||||
// Named by their own headings, so the sheet reads like the page.
|
||||
const cardLabels = {
|
||||
"control": "charging.control.title",
|
||||
"rfid": "charging.rfid.title",
|
||||
"settings": "charging.modbus.settingsTitle",
|
||||
"connection": "charging.control.connectionTitle",
|
||||
"readings": "charging.modbus.title",
|
||||
"info": "charging.info.title",
|
||||
|
||||
Reference in New Issue
Block a user