Compare commits
21
Commits
9d4aecb668
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c34b708b9 | ||
|
|
d4b9d0870e | ||
|
|
fe1e314df9 | ||
|
|
9a2a4ab72e | ||
|
|
181f55a849 | ||
|
|
2b4f4f034d | ||
|
|
0f48093d1a | ||
|
|
5a4515978f | ||
|
|
8e4c22cfcf | ||
|
|
4bcf1305be | ||
|
|
dd636bbe03 | ||
|
|
4ff73e5110 | ||
|
|
c1b76a801d | ||
|
|
830ef0cde5 | ||
|
|
263d35c688 | ||
|
|
4d51a34c44 | ||
|
|
c65ce0c081 | ||
|
|
7b339d3dac | ||
|
|
e190364c77 | ||
|
|
dde5410788 | ||
|
|
3fddab6815 |
@@ -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 {
|
||||
@@ -73,6 +92,10 @@ func main() {
|
||||
// endpoints answer 503 until the read succeeds.
|
||||
_ = srv.StartPlugins()
|
||||
|
||||
// The home-charger scheduler's clock. A schedule that only fires while a
|
||||
// browser tab is open is a reminder, not a schedule, so it is watched here.
|
||||
srv.StartScheduler()
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: srv.Handler(),
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
package api
|
||||
|
||||
// The home-charger scheduler: the user's own list of charging tasks.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// GET /api/charging-tasks — the caller's tasks, earliest first
|
||||
// POST /api/charging-tasks — write one
|
||||
// PATCH /api/charging-tasks/{id} — edit one (any subset of its fields)
|
||||
// DELETE /api/charging-tasks/{id} — forget one
|
||||
// POST /api/charging-tasks/{id}/steps/{n}/run — fire one step now
|
||||
//
|
||||
// A task belongs to the person, like the chargers it acts on, so there is no
|
||||
// sharing here: everyone sees their own list only. Firing one is the business of
|
||||
// chargingtasks_run.go, which is also what the ticker calls.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// The actions a step may carry. Each is a control command the charger already
|
||||
// understands; the scheduler adds no verbs of its own, so a task can only ask
|
||||
// for something the buttons on the Charging page can ask for by hand.
|
||||
var taskActions = map[string]bool{
|
||||
"start": true,
|
||||
"stop": true,
|
||||
"limit": true,
|
||||
"boost": true,
|
||||
}
|
||||
|
||||
// maxTaskSteps bounds one task's flow. Well past any real schedule — a night
|
||||
// rate is two or three steps — and it keeps a client from parking an unbounded
|
||||
// blob on the record, which the sweep then reads every thirty seconds.
|
||||
const maxTaskSteps = 24
|
||||
|
||||
// chargingTaskRecord is the PocketBase-facing shape of a task.
|
||||
type chargingTaskRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Chargers []string `json:"chargers"`
|
||||
Steps []models.ChargingStep `json:"steps"`
|
||||
Zone string `json:"zone"`
|
||||
Days []int `json:"days"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastRun string `json:"last_run"`
|
||||
LastResult string `json:"last_result"`
|
||||
Owner string `json:"owner"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
func (rec chargingTaskRecord) toModel() models.ChargingTask {
|
||||
// The lists are never null on the wire: the page iterates them, and a null
|
||||
// would make "every charger" and "every day" read as an error there.
|
||||
chargers := rec.Chargers
|
||||
if chargers == nil {
|
||||
chargers = []string{}
|
||||
}
|
||||
days := rec.Days
|
||||
if days == nil {
|
||||
days = []int{}
|
||||
}
|
||||
steps := rec.Steps
|
||||
if steps == nil {
|
||||
steps = []models.ChargingStep{}
|
||||
}
|
||||
return models.ChargingTask{
|
||||
ID: rec.ID,
|
||||
Name: rec.Name,
|
||||
Chargers: chargers,
|
||||
Steps: steps,
|
||||
Zone: rec.Zone,
|
||||
Days: days,
|
||||
Enabled: rec.Enabled,
|
||||
LastRun: rec.LastRun,
|
||||
LastResult: rec.LastResult,
|
||||
Created: rec.Created,
|
||||
}
|
||||
}
|
||||
|
||||
// firstStepTime is the time of day a task begins — its first step's, which is
|
||||
// what the list is ordered by, so the evening's task sits below the morning's. A
|
||||
// task with no steps cannot exist, but the ordering must not depend on that.
|
||||
func (rec chargingTaskRecord) firstStepTime() string {
|
||||
if len(rec.Steps) == 0 {
|
||||
return "99:99"
|
||||
}
|
||||
return rec.Steps[0].Time
|
||||
}
|
||||
|
||||
// chargingTaskBody is the write shape. Every field is a pointer so a PATCH can
|
||||
// name one of them without the rest being read as "clear these" — a task
|
||||
// switched off from the list must not lose its flow on the way.
|
||||
type chargingTaskBody struct {
|
||||
Name *string `json:"name"`
|
||||
Chargers *[]string `json:"chargers"`
|
||||
Steps *[]models.ChargingStep `json:"steps"`
|
||||
Zone *string `json:"zone"`
|
||||
Days *[]int `json:"days"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// parseHHMM splits a 24-hour clock time into its two numbers. "H:MM" is accepted
|
||||
// as well as "HH:MM"; anything else is not a time of day.
|
||||
func parseHHMM(raw string) (int, int, bool) {
|
||||
hh, mm, found := strings.Cut(strings.TrimSpace(raw), ":")
|
||||
if !found || len(hh) == 0 || len(hh) > 2 || len(mm) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
h, err := strconv.Atoi(hh)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
m, err := strconv.Atoi(mm)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return h, m, true
|
||||
}
|
||||
|
||||
// normalizeTaskTime returns the padded 24-hour form of a time of day, or "" for
|
||||
// anything that is not one.
|
||||
func normalizeTaskTime(raw string) string {
|
||||
h, m, ok := parseHHMM(raw)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%02d:%02d", h, m)
|
||||
}
|
||||
|
||||
// normalizeDays drops anything that is not a weekday number and sorts what is
|
||||
// left, so the stored list reads the same however the client sent it. Empty
|
||||
// stays empty, which means every day.
|
||||
func normalizeDays(days []int) []int {
|
||||
seen := [7]bool{}
|
||||
for _, d := range days {
|
||||
if d >= 0 && d <= 6 {
|
||||
seen[d] = true
|
||||
}
|
||||
}
|
||||
out := []int{}
|
||||
for d, ok := range seen {
|
||||
if ok {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeSteps validates a flow, keeping the steps in the order they were
|
||||
// written.
|
||||
//
|
||||
// Not sorted by time: a night crosses midnight, and clock order would file
|
||||
// "start at 23:00" last, behind the stop that closes it. Nothing about firing
|
||||
// depends on the order — every step is timed on its own — so the order is free
|
||||
// to be the one that reads as the intention it is.
|
||||
func normalizeSteps(steps []models.ChargingStep) ([]models.ChargingStep, error) {
|
||||
if len(steps) == 0 {
|
||||
return nil, fmt.Errorf("a task needs at least one step")
|
||||
}
|
||||
if len(steps) > maxTaskSteps {
|
||||
return nil, fmt.Errorf("a task can hold at most %d steps", maxTaskSteps)
|
||||
}
|
||||
out := make([]models.ChargingStep, 0, len(steps))
|
||||
for i, step := range steps {
|
||||
action := strings.TrimSpace(step.Action)
|
||||
if !taskActions[action] {
|
||||
return nil, fmt.Errorf("step %d: unknown action: %s", i+1, action)
|
||||
}
|
||||
at := normalizeTaskTime(step.Time)
|
||||
if at == "" {
|
||||
return nil, fmt.Errorf("step %d: time must be a 24-hour clock time, e.g. 23:00", i+1)
|
||||
}
|
||||
// The ceiling only means anything to "limit", but it is kept whatever the
|
||||
// action is: switching a step to "limit" and back should not lose the amps
|
||||
// that were typed. 6 A is the charger's own floor — below it the box pauses
|
||||
// rather than charging slowly — and its ratings top out at 32 A.
|
||||
if step.Amps != 0 && (step.Amps < 6 || step.Amps > 32) {
|
||||
return nil, fmt.Errorf("step %d: the current limit must be between 6 and 32 A", i+1)
|
||||
}
|
||||
// A limit step with no ceiling would fire and ask the charger for 0 A.
|
||||
if action == "limit" && step.Amps <= 0 {
|
||||
return nil, fmt.Errorf("step %d: a limit step needs the amps to limit to", i+1)
|
||||
}
|
||||
out = append(out, models.ChargingStep{Action: action, Amps: step.Amps, Time: at})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// taskPayload turns a write body into the PocketBase fields it names, validating
|
||||
// as it goes. `full` demands the fields a task cannot exist without, so a POST is
|
||||
// checked as a whole and a PATCH only where it speaks.
|
||||
func taskPayload(body chargingTaskBody, full bool) (map[string]any, error) {
|
||||
payload := map[string]any{}
|
||||
|
||||
if body.Name != nil {
|
||||
name := strings.TrimSpace(*body.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("name is required")
|
||||
}
|
||||
payload["name"] = name
|
||||
} else if full {
|
||||
return nil, fmt.Errorf("name is required")
|
||||
}
|
||||
|
||||
if body.Steps != nil {
|
||||
steps, err := normalizeSteps(*body.Steps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload["steps"] = steps
|
||||
} else if full {
|
||||
return nil, fmt.Errorf("a task needs at least one step")
|
||||
}
|
||||
|
||||
if body.Chargers != nil {
|
||||
ids := []string{}
|
||||
for _, id := range *body.Chargers {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
payload["chargers"] = ids
|
||||
}
|
||||
if body.Days != nil {
|
||||
payload["days"] = normalizeDays(*body.Days)
|
||||
}
|
||||
if body.Zone != nil {
|
||||
payload["zone"] = strings.TrimSpace(*body.Zone)
|
||||
}
|
||||
if body.Enabled != nil {
|
||||
payload["enabled"] = *body.Enabled
|
||||
} else if full {
|
||||
// A task written without saying otherwise is on: nobody fills in a
|
||||
// schedule in order to leave it switched off.
|
||||
payload["enabled"] = true
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// listChargingTasks returns the caller's own tasks, the one that starts earliest
|
||||
// first — the order the day runs them in, which is the order a schedule is read
|
||||
// in. Ordered here rather than by PocketBase because the time a task starts is
|
||||
// now inside its flow, which is not a column to sort on.
|
||||
func (s *Server) listChargingTasks(w http.ResponseWriter, r *http.Request) {
|
||||
me := s.currentUserID(r)
|
||||
if me == "" {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
res, err := s.pb.List(r.Context(), colChargingTasks, url.Values{
|
||||
"filter": {fmt.Sprintf("owner='%s'", me)},
|
||||
"perPage": {"200"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var recs []chargingTaskRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
sort.SliceStable(recs, func(i, j int) bool {
|
||||
return recs[i].firstStepTime() < recs[j].firstStepTime()
|
||||
})
|
||||
out := make([]models.ChargingTask, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, rec.toModel())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": out})
|
||||
}
|
||||
|
||||
// ownedChargingTask fetches one task and checks it is the caller's. It writes the
|
||||
// error response itself and returns ok=false when it is not.
|
||||
func (s *Server) ownedChargingTask(w http.ResponseWriter, r *http.Request) (chargingTaskRecord, bool) {
|
||||
var rec chargingTaskRecord
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "task id is required")
|
||||
return rec, false
|
||||
}
|
||||
if err := s.pb.GetOne(r.Context(), colChargingTasks, id, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return rec, false
|
||||
}
|
||||
// Someone else's task is not found rather than forbidden, as a charger is:
|
||||
// whether a record exists is not this caller's business either.
|
||||
if rec.Owner != s.currentUserID(r) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return rec, false
|
||||
}
|
||||
return rec, true
|
||||
}
|
||||
|
||||
func (s *Server) createChargingTask(w http.ResponseWriter, r *http.Request) {
|
||||
me := s.currentUserID(r)
|
||||
if me == "" {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
var body chargingTaskBody
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
payload, err := taskPayload(body, true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
payload["owner"] = me
|
||||
var rec chargingTaskRecord
|
||||
if err := s.pb.Create(r.Context(), colChargingTasks, payload, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"task": rec.toModel()})
|
||||
}
|
||||
|
||||
func (s *Server) updateChargingTask(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := s.ownedChargingTask(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body chargingTaskBody
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
payload, err := taskPayload(body, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task": rec.toModel()})
|
||||
return
|
||||
}
|
||||
var updated chargingTaskRecord
|
||||
if err := s.pb.Update(r.Context(), colChargingTasks, rec.ID, payload, &updated); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task": updated.toModel()})
|
||||
}
|
||||
|
||||
func (s *Server) deleteChargingTask(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := s.ownedChargingTask(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), colChargingTasks, rec.ID); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// runChargingStepNow fires one step of a task on demand — the "Run now" beside
|
||||
// it. One step rather than the whole flow, because a flow is not a thing that
|
||||
// can happen at once: running a start and the stop that closes it back to back
|
||||
// would leave the charger where it began and prove nothing.
|
||||
//
|
||||
// It takes the same path the ticker does, so what comes back is exactly what
|
||||
// that step will do at its own time, errors included. A switched-off task still
|
||||
// runs from here: the switch says whether the clock fires it, not whether the
|
||||
// button does.
|
||||
func (s *Server) runChargingStepNow(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := s.ownedChargingTask(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(r.PathValue("step")))
|
||||
if err != nil || n < 0 || n >= len(rec.Steps) {
|
||||
writeError(w, http.StatusNotFound, "this task has no such step")
|
||||
return
|
||||
}
|
||||
who := caller(r)
|
||||
if who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
step := rec.Steps[n]
|
||||
results := s.fireChargingSteps(r.Context(), who, rec, []models.ChargingStep{step})
|
||||
s.recordTaskRun(r.Context(), rec, step, results)
|
||||
// The same line that was just stored, so the row the caller updates from this
|
||||
// answer reads identically to the one a page reload would fetch.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"results": results,
|
||||
"summary": taskRunLine(step, results),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package api
|
||||
|
||||
// Firing the scheduler's tasks: the ticker that watches the clock, and the one
|
||||
// path a task takes whichever set it off.
|
||||
//
|
||||
// The scheduler is only worth having if it fires with nobody looking, so the
|
||||
// clock is watched here rather than in the browser — a schedule that needs the
|
||||
// page open is a reminder, not a schedule. The ticker sweeps every enabled task
|
||||
// on the server, of every user, and fires whichever ones' minute has come.
|
||||
//
|
||||
// A task acts by sending exactly the control command the Charging page's own
|
||||
// buttons send: the request is built here and handed to the same handler, so it
|
||||
// goes through the same cascade, the same ownership gate, the same rate limit
|
||||
// and the same audit trail. Nothing about a scheduled command is privileged —
|
||||
// what the owner cannot press by hand, the scheduler cannot send for them.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata" // tasks are timed in the user's own zone; hosts without a zone database are common on Windows
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// How often the clock is looked at. Tasks are timed to the minute, so a sweep
|
||||
// twice a minute is enough to land in every minute without the drift a
|
||||
// once-a-minute ticker accumulates.
|
||||
const taskTick = 30 * time.Second
|
||||
|
||||
// How long one sweep may take. Generous rather than tight: the cloud transport
|
||||
// alone allows 45 seconds for a single command, and a task can name several
|
||||
// chargers. A sweep that overran would not overlap the next one — the ticker
|
||||
// drops a tick nobody is waiting on — but it would abandon chargers halfway
|
||||
// down a task's list, which is the worst of both.
|
||||
const taskSweepTimeout = 5 * time.Minute
|
||||
|
||||
// taskRunResult is what happened to one charger in one firing.
|
||||
type taskRunResult struct {
|
||||
Charger string `json:"charger"` // home charger record id
|
||||
Name string `json:"name"`
|
||||
Serial string `json:"serial,omitempty"`
|
||||
Status string `json:"status,omitempty"` // the charger's own answer ("Accepted", …)
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// StartScheduler starts the ticker that fires due charging tasks. It is safe to
|
||||
// call before PocketBase is reachable: a sweep that cannot read the tasks simply
|
||||
// finds none and the next one tries again.
|
||||
//
|
||||
// One server runs one scheduler. Two API Servers pointed at the same database
|
||||
// would both sweep and both fire; the deployment is a single server (see the
|
||||
// README), and the last_run guard below narrows the window rather than closing
|
||||
// it.
|
||||
func (s *Server) StartScheduler() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.schedulerStop = cancel
|
||||
go func() {
|
||||
t := time.NewTicker(taskTick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.sweepChargingTasks(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// sweepChargingTasks fires every task whose minute has come.
|
||||
func (s *Server) sweepChargingTasks(ctx context.Context) {
|
||||
if !s.pb.Configured() {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, taskSweepTimeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := s.pb.List(ctx, colChargingTasks, url.Values{
|
||||
"filter": {"enabled=true"},
|
||||
"perPage": {"500"},
|
||||
})
|
||||
if err != nil {
|
||||
// A database that is not answering is not an error worth a line every
|
||||
// thirty seconds; the tasks are still there when it comes back.
|
||||
return
|
||||
}
|
||||
var recs []chargingTaskRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for _, rec := range recs {
|
||||
due := dueSteps(rec, now)
|
||||
if len(due) == 0 {
|
||||
continue
|
||||
}
|
||||
who, _, err := s.callerForUser(ctx, rec.Owner)
|
||||
if err != nil {
|
||||
log.Printf("scheduler: task %s (%s): owner unavailable: %v", rec.ID, rec.Name, err)
|
||||
continue
|
||||
}
|
||||
// Two steps of one task timed to the same minute contradict each other,
|
||||
// but nothing stops somebody writing them, so both are sent in the order
|
||||
// the flow holds them rather than one silently winning.
|
||||
for _, step := range due {
|
||||
results := s.fireChargingSteps(ctx, who, rec, []models.ChargingStep{step})
|
||||
s.recordTaskRun(ctx, rec, step, results)
|
||||
log.Printf("scheduler: task %s (%s) step %s %s: %s",
|
||||
rec.ID, rec.Name, step.Time, step.Action, summarizeTaskRun(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dueSteps returns the steps of a task whose minute has come — usually one, and
|
||||
// none at all for the rest of the day.
|
||||
//
|
||||
// The times are read in the task's own zone: the browser that wrote it said
|
||||
// which, and the server's clock is not the one the user set 23:00 by. A zone the
|
||||
// host has no database for falls back to the server's own rather than silently
|
||||
// shifting the schedule to UTC.
|
||||
//
|
||||
// A minute that passed while the server was down is not caught up afterwards. A
|
||||
// charging window that opened an hour ago is not a window anyone still wants
|
||||
// opened, and firing a backlog on boot would be the surprising half of the
|
||||
// choice.
|
||||
func dueSteps(rec chargingTaskRecord, now time.Time) []models.ChargingStep {
|
||||
loc := time.Local
|
||||
if rec.Zone != "" {
|
||||
if l, err := time.LoadLocation(rec.Zone); err == nil {
|
||||
loc = l
|
||||
}
|
||||
}
|
||||
local := now.In(loc)
|
||||
if len(rec.Days) > 0 && !containsDay(rec.Days, int(local.Weekday())) {
|
||||
return nil
|
||||
}
|
||||
// The guard against firing twice: the sweep runs more often than once a
|
||||
// minute, so a task that has already run inside this minute is done. It is
|
||||
// per task rather than per step because two steps never share a minute in
|
||||
// any flow that means anything — and when they do, they are sent together.
|
||||
if last, err := time.Parse(time.RFC3339, rec.LastRun); err == nil {
|
||||
if !last.Before(local.Truncate(time.Minute)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var due []models.ChargingStep
|
||||
for _, step := range rec.Steps {
|
||||
h, m, ok := parseHHMM(step.Time)
|
||||
if !ok {
|
||||
continue // a step whose time is not a time of day fires nothing
|
||||
}
|
||||
if local.Hour() == h && local.Minute() == m {
|
||||
due = append(due, step)
|
||||
}
|
||||
}
|
||||
return due
|
||||
}
|
||||
|
||||
func containsDay(days []int, day int) bool {
|
||||
for _, d := range days {
|
||||
if d == day {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fireChargingSteps sends the given steps to each of the task's chargers and
|
||||
// reports what each one said. A task naming no chargers acts on every charger
|
||||
// its owner has — "all of them" is a standing wish, so a charger imported after
|
||||
// the task was written is covered by it too.
|
||||
func (s *Server) fireChargingSteps(ctx context.Context, who *callerIdentity,
|
||||
rec chargingTaskRecord, steps []models.ChargingStep) []taskRunResult {
|
||||
chargers, err := s.ownerChargers(ctx, rec.Owner)
|
||||
if err != nil {
|
||||
return []taskRunResult{{Error: err.Error()}}
|
||||
}
|
||||
wanted := rec.Chargers
|
||||
results := []taskRunResult{}
|
||||
for _, c := range chargers {
|
||||
if len(wanted) > 0 && !containsID(wanted, c.ID) {
|
||||
continue
|
||||
}
|
||||
out := taskRunResult{Charger: c.ID, Name: c.Name, Serial: c.Serial}
|
||||
if c.Serial == "" {
|
||||
// Every transport addresses a charger by its serial, so one without
|
||||
// is not a charger anything can be sent to.
|
||||
out.Error = "this charger has no serial number, so no command can be addressed to it"
|
||||
results = append(results, out)
|
||||
continue
|
||||
}
|
||||
for _, step := range steps {
|
||||
one := out
|
||||
status, err := s.sendStep(ctx, who, c.Serial, step)
|
||||
if err != nil {
|
||||
one.Error = err.Error()
|
||||
} else {
|
||||
one.Status = status
|
||||
}
|
||||
results = append(results, one)
|
||||
}
|
||||
}
|
||||
if len(results) == 0 {
|
||||
results = append(results, taskRunResult{Error: "this task names no charger that still exists"})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// ownerChargers reads one user's imported chargers. The scheduler reads them
|
||||
// itself rather than trusting the ids on the task: a charger removed from the
|
||||
// account should stop being acted on, whether or not the task was edited.
|
||||
func (s *Server) ownerChargers(ctx context.Context, owner string) ([]homeChargerRecord, error) {
|
||||
res, err := s.pb.List(ctx, colHomeChargers, url.Values{
|
||||
"filter": {fmt.Sprintf("owner='%s'", owner)},
|
||||
"perPage": {"200"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var recs []homeChargerRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return recs, nil
|
||||
}
|
||||
|
||||
func containsID(ids []string, id string) bool {
|
||||
for _, x := range ids {
|
||||
if x == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sendStep sends one step's command to one charger, through the control endpoint
|
||||
// the page's own buttons use.
|
||||
//
|
||||
// The request is synthesised rather than the transports being called directly,
|
||||
// so a scheduled command cannot end up on a different footing from a pressed
|
||||
// one: the cascade, the ownership gate, the rate limit and the audit line are
|
||||
// the endpoint's, and there is no second copy of them here to drift.
|
||||
func (s *Server) sendStep(ctx context.Context, who *callerIdentity, serial string, step models.ChargingStep) (string, error) {
|
||||
body := map[string]any{}
|
||||
if step.Action == "limit" {
|
||||
body["amps"] = step.Amps
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + step.Action
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.WithValue(ctx, ctxCaller, who), http.MethodPost, path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// The endpoint reads its charger and its verb from the route, which nothing
|
||||
// matched here — this request never went through a mux.
|
||||
req.SetPathValue("sn", serial)
|
||||
req.SetPathValue("action", step.Action)
|
||||
|
||||
rw := &captureWriter{header: http.Header{}}
|
||||
s.handleAnkerControlAction(rw, req)
|
||||
return rw.controlOutcome()
|
||||
}
|
||||
|
||||
// captureWriter is an http.ResponseWriter that keeps the response in memory,
|
||||
// so a handler can be called without a connection to write down.
|
||||
type captureWriter struct {
|
||||
status int
|
||||
header http.Header
|
||||
body bytes.Buffer
|
||||
}
|
||||
|
||||
func (c *captureWriter) Header() http.Header { return c.header }
|
||||
|
||||
func (c *captureWriter) WriteHeader(status int) {
|
||||
if c.status == 0 {
|
||||
c.status = status
|
||||
}
|
||||
}
|
||||
|
||||
func (c *captureWriter) Write(p []byte) (int, error) {
|
||||
if c.status == 0 {
|
||||
c.status = http.StatusOK
|
||||
}
|
||||
return c.body.Write(p)
|
||||
}
|
||||
|
||||
// controlOutcome reads a captured control response as the charger's answer or
|
||||
// the reason there wasn't one. The endpoint answers in one of two shapes — a
|
||||
// status on success, an error envelope otherwise — and both are read here rather
|
||||
// than the status code alone, because "why not" is the half worth keeping.
|
||||
func (c *captureWriter) controlOutcome() (string, error) {
|
||||
var out struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(c.body.Bytes(), &out)
|
||||
if c.status >= 200 && c.status < 300 {
|
||||
if out.Status == "" {
|
||||
return "sent", nil
|
||||
}
|
||||
return out.Status, nil
|
||||
}
|
||||
if out.Error != "" {
|
||||
return "", fmt.Errorf("%s", out.Error)
|
||||
}
|
||||
return "", fmt.Errorf("the charger could not be reached (HTTP %d)", c.status)
|
||||
}
|
||||
|
||||
// summarizeTaskRun says how a firing went in one line — what the list shows
|
||||
// beside a task, and what goes in the log.
|
||||
func summarizeTaskRun(results []taskRunResult) string {
|
||||
ok, failed := 0, []string{}
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
ok++
|
||||
continue
|
||||
}
|
||||
name := r.Name
|
||||
if name == "" {
|
||||
name = r.Serial
|
||||
}
|
||||
if name != "" {
|
||||
failed = append(failed, name+": "+r.Error)
|
||||
} else {
|
||||
failed = append(failed, r.Error)
|
||||
}
|
||||
}
|
||||
sort.Strings(failed)
|
||||
switch {
|
||||
case len(failed) == 0:
|
||||
return fmt.Sprintf("%d of %d sent", ok, len(results))
|
||||
case ok == 0 && len(failed) == 1:
|
||||
return failed[0]
|
||||
default:
|
||||
return fmt.Sprintf("%d of %d sent — %s", ok, len(results), strings.Join(failed, "; "))
|
||||
}
|
||||
}
|
||||
|
||||
// taskRunLine is the outcome of one firing as the list shows it: which step, and
|
||||
// how it went. A task holds several steps now, and "2 of 2 sent" says nothing
|
||||
// about which of them it was.
|
||||
//
|
||||
// The step is named by its time rather than its action, because everything this
|
||||
// server writes into last_result is in its own words — charger names, the
|
||||
// control gate's refusals — while the page has a translation for every action.
|
||||
// A clock time reads the same in all three languages.
|
||||
func taskRunLine(step models.ChargingStep, results []taskRunResult) string {
|
||||
return step.Time + " — " + summarizeTaskRun(results)
|
||||
}
|
||||
|
||||
// recordTaskRun stamps a task with when it last fired and how it went.
|
||||
//
|
||||
// The stamp is also the guard that keeps a task from firing twice inside its
|
||||
// minute, so a firing that could not be written down is worth a log line:
|
||||
// without it the next sweep would send the command again.
|
||||
func (s *Server) recordTaskRun(ctx context.Context, rec chargingTaskRecord,
|
||||
step models.ChargingStep, results []taskRunResult) {
|
||||
payload := map[string]any{
|
||||
"last_run": time.Now().UTC().Format(time.RFC3339),
|
||||
"last_result": taskRunLine(step, results),
|
||||
}
|
||||
if err := s.pb.Update(ctx, colChargingTasks, rec.ID, payload, nil); err != nil {
|
||||
log.Printf("scheduler: task %s fired but its outcome could not be saved: %v", rec.ID, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// The scheduler's clock is the part worth pinning down: it decides on its own,
|
||||
// with nobody watching, whether to send a car a command. A step that fires in
|
||||
// the wrong hour is worse than one that does not fire at all, so the zone, the
|
||||
// weekday and the not-twice-in-one-minute guard are each checked here.
|
||||
|
||||
func step(action, at string, amps float64) models.ChargingStep {
|
||||
return models.ChargingStep{Action: action, Time: at, Amps: amps}
|
||||
}
|
||||
|
||||
func TestDueSteps(t *testing.T) {
|
||||
warsaw, err := time.LoadLocation("Europe/Warsaw")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/Warsaw: %v", err)
|
||||
}
|
||||
// A Wednesday, 23:00 in Warsaw — 22:00 UTC.
|
||||
at2300 := time.Date(2026, 9, 2, 23, 0, 30, 0, warsaw)
|
||||
|
||||
// A whole night in one task, which is the point of a flow: it opens, it
|
||||
// eases off, it closes.
|
||||
base := chargingTaskRecord{
|
||||
Zone: "Europe/Warsaw",
|
||||
Enabled: true,
|
||||
Steps: []models.ChargingStep{
|
||||
step("start", "23:00", 0),
|
||||
step("limit", "01:00", 10),
|
||||
step("stop", "06:30", 0),
|
||||
},
|
||||
}
|
||||
|
||||
due := dueSteps(base, at2300)
|
||||
if len(due) != 1 || due[0].Action != "start" {
|
||||
t.Fatalf("at 23:00 got %+v, want just the start step", due)
|
||||
}
|
||||
// The same instant handed over as UTC. The zone on the task is what the
|
||||
// times are read in, so where the server thinks it is must not matter.
|
||||
if got := dueSteps(base, at2300.UTC()); len(got) != 1 || got[0].Action != "start" {
|
||||
t.Errorf("the step stopped being due when the same instant arrived as UTC: %+v", got)
|
||||
}
|
||||
// The other two steps, each in its own minute and no other.
|
||||
if got := dueSteps(base, time.Date(2026, 9, 3, 1, 0, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "limit" {
|
||||
t.Errorf("at 01:00 got %+v, want the limit step", got)
|
||||
}
|
||||
if got := dueSteps(base, time.Date(2026, 9, 3, 6, 30, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "stop" {
|
||||
t.Errorf("at 06:30 got %+v, want the stop step", got)
|
||||
}
|
||||
// A minute the flow says nothing about fires nothing — the point being that
|
||||
// a task with a step at 23:00 is not "on" from 23:00 onwards.
|
||||
for _, at := range []time.Time{at2300.Add(time.Minute), at2300.Add(-time.Minute),
|
||||
time.Date(2026, 9, 3, 3, 0, 0, 0, warsaw)} {
|
||||
if got := dueSteps(base, at); len(got) != 0 {
|
||||
t.Errorf("at %s got %+v, want nothing due", at.Format("15:04"), got)
|
||||
}
|
||||
}
|
||||
|
||||
// Weekdays gate the whole task. 2026-09-02 is a Wednesday (3).
|
||||
weeknights := base
|
||||
weeknights.Days = []int{1, 2, 3, 4, 5}
|
||||
if len(dueSteps(weeknights, at2300)) != 1 {
|
||||
t.Error("a weeknight task did not fire on a Wednesday")
|
||||
}
|
||||
weekends := base
|
||||
weekends.Days = []int{0, 6}
|
||||
if len(dueSteps(weekends, at2300)) != 0 {
|
||||
t.Error("a weekend task fired on a Wednesday")
|
||||
}
|
||||
|
||||
// Fired already inside this minute: the sweep runs more than once a minute,
|
||||
// and the second sweep must not send the command again.
|
||||
fired := base
|
||||
fired.LastRun = at2300.UTC().Format(time.RFC3339)
|
||||
if len(dueSteps(fired, at2300.Add(20*time.Second))) != 0 {
|
||||
t.Error("a step fired twice inside its own minute")
|
||||
}
|
||||
// Yesterday's firing is not this minute's.
|
||||
yesterday := base
|
||||
yesterday.LastRun = at2300.Add(-24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
if len(dueSteps(yesterday, at2300)) != 1 {
|
||||
t.Error("yesterday's run stopped today's from firing")
|
||||
}
|
||||
|
||||
// A step whose time is not a time of day fires nothing rather than firing at
|
||||
// midnight, which is what a zero hour and minute would have meant — and it
|
||||
// does not take the rest of the flow down with it.
|
||||
broken := base
|
||||
broken.Steps = []models.ChargingStep{step("start", "later", 0), step("stop", "23:00", 0)}
|
||||
got := dueSteps(broken, at2300)
|
||||
if len(got) != 1 || got[0].Action != "stop" {
|
||||
t.Errorf("got %+v, want only the readable step", got)
|
||||
}
|
||||
|
||||
// Two steps timed to the same minute contradict each other, but nothing
|
||||
// stops somebody writing them, so both are returned rather than one
|
||||
// silently winning.
|
||||
clash := base
|
||||
clash.Steps = []models.ChargingStep{step("start", "23:00", 0), step("boost", "23:00", 0)}
|
||||
if got := dueSteps(clash, at2300); len(got) != 2 {
|
||||
t.Errorf("got %+v, want both steps sharing the minute", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A zone the host has no database for falls back to the server's own clock
|
||||
// rather than silently shifting the schedule to UTC.
|
||||
func TestDueStepsUnknownZone(t *testing.T) {
|
||||
now := time.Now()
|
||||
rec := chargingTaskRecord{
|
||||
Zone: "Mars/Olympus_Mons",
|
||||
Steps: []models.ChargingStep{step("start", now.Format("15:04"), 0)},
|
||||
}
|
||||
if len(dueSteps(rec, now)) != 1 {
|
||||
t.Error("a task in an unknown zone did not fall back to the server's clock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHHMM(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
h, m int
|
||||
wantOK bool
|
||||
}{
|
||||
{in: "23:00", h: 23, m: 0, wantOK: true},
|
||||
{in: "7:05", h: 7, m: 5, wantOK: true},
|
||||
{in: " 00:00 ", h: 0, m: 0, wantOK: true},
|
||||
{in: "24:00"},
|
||||
{in: "12:60"},
|
||||
{in: "12:5"}, // a half-typed minute is not a minute
|
||||
{in: "123:00"}, // nor is a three-digit hour
|
||||
{in: "12"},
|
||||
{in: ""},
|
||||
{in: "ab:cd"},
|
||||
} {
|
||||
h, m, ok := parseHHMM(tc.in)
|
||||
if ok != tc.wantOK {
|
||||
t.Errorf("parseHHMM(%q) ok = %v, want %v", tc.in, ok, tc.wantOK)
|
||||
continue
|
||||
}
|
||||
if ok && (h != tc.h || m != tc.m) {
|
||||
t.Errorf("parseHHMM(%q) = %d:%d, want %d:%d", tc.in, h, m, tc.h, tc.m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDays(t *testing.T) {
|
||||
got := normalizeDays([]int{5, 1, 5, 9, -1, 0})
|
||||
want := []int{0, 1, 5}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("normalizeDays = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("normalizeDays = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
// Empty means every day and stays empty rather than becoming all seven —
|
||||
// the two read the same today, but only one keeps meaning "every day".
|
||||
if len(normalizeDays(nil)) != 0 {
|
||||
t.Error("normalizeDays(nil) invented days")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSteps(t *testing.T) {
|
||||
// A night, written the way it is meant: it opens, it eases off, it closes.
|
||||
// The half-written times are the client's business to send and this
|
||||
// function's business to pad.
|
||||
got, err := normalizeSteps([]models.ChargingStep{
|
||||
step("start", "23:00", 0),
|
||||
step("limit", "1:00", 10),
|
||||
step("stop", "6:30", 0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeSteps: %v", err)
|
||||
}
|
||||
// Kept in the order it was written. Sorting by the clock would file the
|
||||
// 23:00 start last, behind the stop that closes it, which is not the flow
|
||||
// anybody described — and firing does not depend on the order at all.
|
||||
wantTimes := []string{"23:00", "01:00", "06:30"}
|
||||
for i, want := range wantTimes {
|
||||
if got[i].Time != want {
|
||||
t.Errorf("step %d time = %q, want %q (whole flow: %+v)", i, got[i].Time, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
for name, steps := range map[string][]models.ChargingStep{
|
||||
"no steps at all": {},
|
||||
"an unknown action": {step("melt", "23:00", 0)},
|
||||
"a time that is not one": {step("start", "half past", 0)},
|
||||
"a limit with no amps": {step("limit", "23:00", 0)},
|
||||
"amps below the floor": {step("limit", "23:00", 3)},
|
||||
"amps above the rating": {step("limit", "23:00", 40)},
|
||||
} {
|
||||
if _, err := normalizeSteps(steps); err == nil {
|
||||
t.Errorf("normalizeSteps accepted %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The cap bounds what the sweep re-reads every thirty seconds.
|
||||
tooMany := make([]models.ChargingStep, maxTaskSteps+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = step("start", "23:00", 0)
|
||||
}
|
||||
if _, err := normalizeSteps(tooMany); err == nil {
|
||||
t.Errorf("normalizeSteps accepted %d steps, past the cap of %d", len(tooMany), maxTaskSteps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskPayloadValidation(t *testing.T) {
|
||||
str := func(s string) *string { return &s }
|
||||
steps := func(s ...models.ChargingStep) *[]models.ChargingStep { return &s }
|
||||
|
||||
full := chargingTaskBody{
|
||||
Name: str(" Night rate "),
|
||||
Steps: steps(step("start", "23:00", 0), step("stop", "6:30", 0)),
|
||||
}
|
||||
payload, err := taskPayload(full, true)
|
||||
if err != nil {
|
||||
t.Fatalf("taskPayload: %v", err)
|
||||
}
|
||||
if payload["name"] != "Night rate" {
|
||||
t.Errorf("name = %v, want it trimmed", payload["name"])
|
||||
}
|
||||
if payload["enabled"] != true {
|
||||
t.Error("a new task was written switched off; nobody fills in a schedule to leave it off")
|
||||
}
|
||||
if flow, _ := payload["steps"].([]models.ChargingStep); len(flow) != 2 || flow[0].Time != "23:00" {
|
||||
t.Errorf("steps = %+v, want both, as written", payload["steps"])
|
||||
}
|
||||
|
||||
// The two things a task cannot exist without.
|
||||
for _, missing := range []chargingTaskBody{
|
||||
{Steps: steps(step("start", "23:00", 0))},
|
||||
{Name: str("x")},
|
||||
{Name: str(" "), Steps: steps(step("start", "23:00", 0))},
|
||||
} {
|
||||
if _, err := taskPayload(missing, true); err == nil {
|
||||
t.Errorf("taskPayload accepted an incomplete task: %+v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// A partial write says only what it names, so a task switched off from the
|
||||
// list keeps its flow, its chargers and its days.
|
||||
on := true
|
||||
partial, err := taskPayload(chargingTaskBody{Enabled: &on}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("taskPayload(partial): %v", err)
|
||||
}
|
||||
if len(partial) != 1 {
|
||||
t.Errorf("a switch-only write touched %v, want only enabled", partial)
|
||||
}
|
||||
}
|
||||
|
||||
// The list is ordered by the time a task begins, which lives inside its flow
|
||||
// rather than in a column PocketBase could sort on.
|
||||
func TestFirstStepTime(t *testing.T) {
|
||||
rec := chargingTaskRecord{Steps: []models.ChargingStep{step("start", "23:00", 0), step("stop", "06:30", 0)}}
|
||||
if got := rec.firstStepTime(); got != "23:00" {
|
||||
t.Errorf("firstStepTime = %q, want the first step's 23:00", got)
|
||||
}
|
||||
// A task with no steps cannot be written, but sorting must not depend on
|
||||
// that — it sorts last rather than first.
|
||||
if got := (chargingTaskRecord{}).firstStepTime(); got < "23:59" {
|
||||
t.Errorf("an empty task sorted to %q, want it last", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeTaskRun(t *testing.T) {
|
||||
all := summarizeTaskRun([]taskRunResult{
|
||||
{Name: "Home-DK", Status: "Accepted"},
|
||||
{Name: "Home-PL", Status: "Accepted"},
|
||||
})
|
||||
if all != "2 of 2 sent" {
|
||||
t.Errorf("summary = %q, want \"2 of 2 sent\"", all)
|
||||
}
|
||||
|
||||
// One charger failing is the case the list has to be able to show: the
|
||||
// summary names which, because "1 of 2 sent" alone is not actionable.
|
||||
some := summarizeTaskRun([]taskRunResult{
|
||||
{Name: "Home-DK", Status: "Accepted"},
|
||||
{Name: "Home-PL", Error: "charger is not connected"},
|
||||
})
|
||||
if !strings.Contains(some, "Home-PL") || !strings.Contains(some, "not connected") {
|
||||
t.Errorf("summary = %q, want it to name the charger that failed and why", some)
|
||||
}
|
||||
|
||||
// A single failure is its own reason — no count to read past.
|
||||
only := summarizeTaskRun([]taskRunResult{{Name: "Home-PL", Error: "control mode is off"}})
|
||||
if only != "Home-PL: control mode is off" {
|
||||
t.Errorf("summary = %q, want the bare reason", only)
|
||||
}
|
||||
}
|
||||
|
||||
// The runner reads a control response the same way whichever transport answered
|
||||
// it: a status when the charger took the command, the endpoint's own words when
|
||||
// it did not.
|
||||
func TestCaptureWriterControlOutcome(t *testing.T) {
|
||||
ok := &captureWriter{header: nil}
|
||||
ok.WriteHeader(200)
|
||||
_, _ = ok.Write([]byte(`{"status":"Accepted"}`))
|
||||
if got, err := ok.controlOutcome(); err != nil || got != "Accepted" {
|
||||
t.Errorf("controlOutcome = %q, %v; want Accepted", got, err)
|
||||
}
|
||||
|
||||
// A 2xx with nothing to read still means the command went.
|
||||
bare := &captureWriter{}
|
||||
bare.WriteHeader(204)
|
||||
if got, err := bare.controlOutcome(); err != nil || got != "sent" {
|
||||
t.Errorf("controlOutcome = %q, %v; want sent", got, err)
|
||||
}
|
||||
|
||||
bad := &captureWriter{}
|
||||
bad.WriteHeader(409)
|
||||
_, _ = bad.Write([]byte(`{"error":"charger is not connected to the control backend"}`))
|
||||
_, err := bad.controlOutcome()
|
||||
if err == nil || !strings.Contains(err.Error(), "not connected") {
|
||||
t.Errorf("controlOutcome err = %v, want the endpoint's own reason", err)
|
||||
}
|
||||
|
||||
// An error status with no envelope to read still has to say something.
|
||||
mute := &captureWriter{}
|
||||
mute.WriteHeader(502)
|
||||
if _, err := mute.controlOutcome(); err == nil {
|
||||
t.Error("a 502 with an empty body was read as a success")
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<title>DriverVault · API Server</title>
|
||||
<script type="module" crossorigin src="/assets/index-BBZfstAT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-_8IdQ1wh.css">
|
||||
<script type="module" crossorigin src="/assets/index-CUgU2Fxs.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cd85xDqG.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -62,6 +62,52 @@ func normalizeControlMode(v string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// ankerControlModesAll lists every control mode, in the order the clients offer
|
||||
// them. off leads because it is the default and the fallback.
|
||||
var ankerControlModesAll = []string{ankerControlOff, ankerControlMqtt, ankerControlModbus, ankerControlOwn, ankerControlProxy}
|
||||
|
||||
// parseControlModes reads a stored comma-separated mode list into a set,
|
||||
// dropping anything unrecognized. off is never in it: monitoring-only is what a
|
||||
// charger falls back to, so no layer may take it away from the layers below.
|
||||
func parseControlModes(v string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if m := normalizeControlMode(part); m != "" && m != ankerControlOff {
|
||||
out[m] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// joinControlModes renders a mode set back to its stored form, in the canonical
|
||||
// order, so what a client sends round-trips to a predictable string.
|
||||
func joinControlModes(set map[string]bool) string {
|
||||
return strings.Join(controlModeList(set), ",")
|
||||
}
|
||||
|
||||
// controlModeList sorts a mode set into the canonical order.
|
||||
func controlModeList(set map[string]bool) []string {
|
||||
out := []string{}
|
||||
for _, m := range ankerControlModesAll {
|
||||
if set[m] {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// controlModesOffered lists the modes a layer may still choose once the layers
|
||||
// above it have hidden theirs.
|
||||
func controlModesOffered(hidden map[string]bool) []string {
|
||||
out := []string{}
|
||||
for _, m := range ankerControlModesAll {
|
||||
if !hidden[m] {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ankerConfig is one layer's Anker Solix settings.
|
||||
type ankerConfig struct {
|
||||
Email string `json:"email"`
|
||||
@@ -70,6 +116,12 @@ type ankerConfig struct {
|
||||
// ControlMode is the control path: off | mqtt | modbus | own | proxy.
|
||||
// It resolves independently of the credentials, like Country.
|
||||
ControlMode string `json:"controlMode"`
|
||||
// ControlModesDisabled is a comma-separated list of modes this layer hides
|
||||
// from the layers below it — a mode a deployment (or an organization) does
|
||||
// not want offered at all. It does not constrain this layer's own choice, and
|
||||
// it never hides off. Meaningful on the global and organization layers only:
|
||||
// a user has nobody below them.
|
||||
ControlModesDisabled string `json:"controlModesDisabled,omitempty"`
|
||||
}
|
||||
|
||||
// ankerChargerBinding is what we know about one charger the caller controls:
|
||||
@@ -128,6 +180,14 @@ type ankerResolution struct {
|
||||
available bool // global master switch (plugin enabled in the panel)
|
||||
orgEnabled bool // org gate (default true; gates the org's users)
|
||||
enabled bool // caller's personal enable flag
|
||||
|
||||
// hiddenForOrg is what the global layer hides from everyone below it;
|
||||
// hiddenForUser adds what the caller's organization hides from its own users.
|
||||
// They gate both the pickers the clients draw and which layer's stored mode
|
||||
// is allowed to take effect.
|
||||
hiddenForOrg map[string]bool
|
||||
hiddenForUser map[string]bool
|
||||
orgHidden map[string]bool // the organization layer's own list, for its editor
|
||||
}
|
||||
|
||||
// ankerLayerRank orders the cascade layers; a higher number is lower priority.
|
||||
@@ -137,7 +197,8 @@ var ankerLayerRank = map[string]int{"global": 1, "org": 2, "user": 3}
|
||||
// pluginSettings blob (read from their user record).
|
||||
func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) ankerResolution {
|
||||
g, masterEnabled, _ := s.plugins.RawConfig(ankerPlugin)
|
||||
gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"], ControlMode: g["controlMode"]}
|
||||
gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"],
|
||||
ControlMode: g["controlMode"], ControlModesDisabled: g["controlModesDisabled"]}
|
||||
|
||||
var oStored ankerStored
|
||||
if who.OrgID != "" {
|
||||
@@ -166,6 +227,21 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
|
||||
enabled: uStored.Enabled,
|
||||
}
|
||||
|
||||
// What each layer below may still be offered. The global list reaches
|
||||
// everyone; an organization's own list reaches only its users, so it narrows
|
||||
// the set once more on the way down.
|
||||
res.hiddenForOrg = parseControlModes(gc.ControlModesDisabled)
|
||||
res.orgHidden = parseControlModes(oc.ControlModesDisabled)
|
||||
res.hiddenForUser = map[string]bool{}
|
||||
for m := range res.hiddenForOrg {
|
||||
res.hiddenForUser[m] = true
|
||||
}
|
||||
if who.OrgID != "" {
|
||||
for m := range res.orgHidden {
|
||||
res.hiddenForUser[m] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Ordered layers, top (highest priority) first.
|
||||
type layer struct {
|
||||
name string
|
||||
@@ -189,13 +265,18 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
|
||||
// Control mode resolves independently too, defaulting to off (monitoring
|
||||
// only). The highest layer that sets a recognized value wins; an explicit
|
||||
// "off" set above still wins (and locks lower layers to monitoring).
|
||||
// A mode hidden by a layer above is not merely absent from the picker: a value
|
||||
// stored below before it was hidden stops taking effect too, so switching a
|
||||
// mode off really does switch it off everywhere underneath.
|
||||
res.eff.ControlMode = ankerControlOff
|
||||
res.source["controlMode"] = "unset"
|
||||
for _, l := range layers {
|
||||
if v := normalizeControlMode(l.c.ControlMode); v != "" {
|
||||
res.eff.ControlMode, res.source["controlMode"] = v, l.name
|
||||
break
|
||||
v := normalizeControlMode(l.c.ControlMode)
|
||||
if v == "" || res.hiddenAt(l.name)[v] {
|
||||
continue
|
||||
}
|
||||
res.eff.ControlMode, res.source["controlMode"] = v, l.name
|
||||
break
|
||||
}
|
||||
|
||||
// Credentials resolve as a pair from the highest layer with an email, so the
|
||||
@@ -212,6 +293,19 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
|
||||
return res
|
||||
}
|
||||
|
||||
// hiddenAt is the set of modes hidden from one cascade layer by the layers above
|
||||
// it. The global layer answers to nobody, so nothing is hidden from it.
|
||||
func (r ankerResolution) hiddenAt(layer string) map[string]bool {
|
||||
switch layer {
|
||||
case "org":
|
||||
return r.hiddenForOrg
|
||||
case "user":
|
||||
return r.hiddenForUser
|
||||
default:
|
||||
return map[string]bool{}
|
||||
}
|
||||
}
|
||||
|
||||
// ankerLockedFor reports whether a field whose value comes from source is locked
|
||||
// for a caller whose editable layer is editable (i.e. the value is set above them).
|
||||
func ankerLockedFor(source, editable string) bool {
|
||||
@@ -301,7 +395,13 @@ func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string
|
||||
}
|
||||
return fv
|
||||
}
|
||||
return map[string]any{
|
||||
// The modes this scope may pick from: everything the layers above it left
|
||||
// standing. A superadmin's read-only view answers as the user layer they are.
|
||||
hidden := res.hiddenForUser
|
||||
if editable == "org" {
|
||||
hidden = res.hiddenForOrg
|
||||
}
|
||||
out := map[string]any{
|
||||
"editableLayer": editable,
|
||||
"fields": map[string]ankerFieldView{
|
||||
"email": field("email", res.eff.Email, own.Email, false),
|
||||
@@ -309,7 +409,15 @@ func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string
|
||||
"country": field("country", res.eff.Country, own.Country, false),
|
||||
"controlMode": field("controlMode", res.eff.ControlMode, own.ControlMode, false),
|
||||
},
|
||||
"controlModes": controlModesOffered(hidden),
|
||||
}
|
||||
if editable == "org" {
|
||||
// The organization's own hide-list, which its admin edits here. Modes the
|
||||
// global layer already hid are not in controlModes above, so they simply
|
||||
// never come up.
|
||||
out["controlModesDisabled"] = controlModeList(res.orgHidden)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ankerView builds the masked, client-safe response body from a resolution.
|
||||
@@ -319,6 +427,7 @@ func (s *Server) ankerView(who *callerIdentity, res ankerResolution) map[string]
|
||||
"orgEnabled": res.orgEnabled,
|
||||
"enabled": res.enabled,
|
||||
"controlMode": res.eff.ControlMode, // effective control mode (off|mqtt|modbus|own|proxy)
|
||||
"controlModes": controlModesOffered(res.hiddenForUser), // modes still offered to this caller
|
||||
"role": who.Role,
|
||||
"orgId": who.OrgID,
|
||||
"canEditOrg": res.canOrg,
|
||||
@@ -430,7 +539,21 @@ func (s *Server) handlePutAnker(w http.ResponseWriter, r *http.Request) {
|
||||
applyField("email", func(c *ankerConfig, v string) { c.Email = v })
|
||||
applyField("password", func(c *ankerConfig, v string) { c.Password = v })
|
||||
applyField("country", func(c *ankerConfig, v string) { c.Country = strings.ToUpper(v) })
|
||||
applyField("controlMode", func(c *ankerConfig, v string) { c.ControlMode = normalizeControlMode(v) })
|
||||
applyField("controlMode", func(c *ankerConfig, v string) {
|
||||
m := normalizeControlMode(v)
|
||||
// A mode hidden above is not a choice this layer can make; keep whatever it
|
||||
// had rather than storing something that would never take effect.
|
||||
if m != "" && res.hiddenAt(editable)[m] {
|
||||
return
|
||||
}
|
||||
c.ControlMode = m
|
||||
})
|
||||
if editable == "org" {
|
||||
// Only a layer with users under it has anything to hide from them.
|
||||
applyField("controlModesDisabled", func(c *ankerConfig, v string) {
|
||||
c.ControlModesDisabled = joinControlModes(parseControlModes(v))
|
||||
})
|
||||
}
|
||||
|
||||
// Persist the organization layer (admins) via the service account.
|
||||
if editable == "org" {
|
||||
|
||||
@@ -241,3 +241,83 @@ func TestAnkerCardRoutesAreRegistered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseControlModes(t *testing.T) {
|
||||
got := parseControlModes(" Proxy , own ,bogus,, off ")
|
||||
// off is never hideable: monitoring only is what a charger falls back to.
|
||||
want := map[string]bool{"proxy": true, "own": true}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parseControlModes = %v, want %v", got, want)
|
||||
}
|
||||
for m := range want {
|
||||
if !got[m] {
|
||||
t.Errorf("parseControlModes is missing %q", m)
|
||||
}
|
||||
}
|
||||
if s := joinControlModes(got); s != "own,proxy" {
|
||||
t.Errorf("joinControlModes = %q, want own,proxy (canonical order)", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlModesOffered(t *testing.T) {
|
||||
got := controlModesOffered(map[string]bool{"own": true, "proxy": true})
|
||||
want := []string{"off", "mqtt", "modbus"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("controlModesOffered = %v, want %v", got, want)
|
||||
}
|
||||
for i, m := range want {
|
||||
if got[i] != m {
|
||||
t.Errorf("controlModesOffered[%d] = %q, want %q", i, got[i], m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mode the global layer hides is not merely absent from a lower picker: a
|
||||
// value stored below before it was hidden stops taking effect too.
|
||||
func TestHiddenControlModeDoesNotTakeEffect(t *testing.T) {
|
||||
res := ankerResolution{
|
||||
hiddenForOrg: map[string]bool{"own": true, "proxy": true},
|
||||
hiddenForUser: map[string]bool{"own": true, "proxy": true},
|
||||
}
|
||||
if !res.hiddenAt("user")["proxy"] {
|
||||
t.Error("proxy should be hidden from the user layer")
|
||||
}
|
||||
if res.hiddenAt("global")["proxy"] {
|
||||
t.Error("nothing is hidden from the global layer — it is the one hiding")
|
||||
}
|
||||
view := (&Server{}).ankerScopeView(res, "user")
|
||||
for _, m := range view["controlModes"].([]string) {
|
||||
if m == "own" || m == "proxy" {
|
||||
t.Errorf("hidden mode %q is still offered to the user scope", m)
|
||||
}
|
||||
}
|
||||
if _, ok := view["controlModesDisabled"]; ok {
|
||||
t.Error("the user scope has nobody below it and must carry no hide-list")
|
||||
}
|
||||
}
|
||||
|
||||
// An organization narrows the set once more for its own users, and its admin
|
||||
// edits that list in the org scope.
|
||||
func TestOrgScopeCarriesItsOwnHideList(t *testing.T) {
|
||||
res := ankerResolution{
|
||||
hiddenForOrg: map[string]bool{"proxy": true},
|
||||
hiddenForUser: map[string]bool{"proxy": true, "own": true},
|
||||
orgHidden: map[string]bool{"own": true},
|
||||
}
|
||||
org := (&Server{}).ankerScopeView(res, "org")
|
||||
offered := org["controlModes"].([]string)
|
||||
var sawOwn, sawProxy bool
|
||||
for _, m := range offered {
|
||||
sawOwn = sawOwn || m == "own"
|
||||
sawProxy = sawProxy || m == "proxy"
|
||||
}
|
||||
if !sawOwn {
|
||||
t.Error("an org may still choose a mode it only hides from its users")
|
||||
}
|
||||
if sawProxy {
|
||||
t.Error("a mode the global layer hid must not reach the org picker")
|
||||
}
|
||||
if got := org["controlModesDisabled"].([]string); len(got) != 1 || got[0] != "own" {
|
||||
t.Errorf("org hide-list = %v, want [own]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ type userRecord struct {
|
||||
Theme string `json:"theme"`
|
||||
Locale string `json:"locale"`
|
||||
DateFormat string `json:"date_format"`
|
||||
TimeFormat string `json:"time_format"`
|
||||
WeekStart string `json:"week_start"`
|
||||
Currency string `json:"currency"`
|
||||
FontSize string `json:"font_size"`
|
||||
DragLocked bool `json:"drag_locked"`
|
||||
@@ -97,6 +99,8 @@ func (rec userRecord) toModel() models.User {
|
||||
Theme: orDefault(rec.Theme, "system"),
|
||||
Locale: orDefault(rec.Locale, "en-US"),
|
||||
DateFormat: orDefault(rec.DateFormat, "YMD"),
|
||||
TimeFormat: orDefault(rec.TimeFormat, "auto"),
|
||||
WeekStart: orDefault(rec.WeekStart, "auto"),
|
||||
Currency: orDefault(rec.Currency, "USD"),
|
||||
FontSize: orDefault(rec.FontSize, "medium"),
|
||||
DragLocked: rec.DragLocked,
|
||||
@@ -161,6 +165,8 @@ type updateMeRequest struct {
|
||||
Theme *string `json:"theme"`
|
||||
Locale *string `json:"locale"`
|
||||
DateFormat *string `json:"dateFormat"`
|
||||
TimeFormat *string `json:"timeFormat"`
|
||||
WeekStart *string `json:"weekStart"`
|
||||
Currency *string `json:"currency"`
|
||||
FontSize *string `json:"fontSize"`
|
||||
DragLocked *bool `json:"dragLocked"`
|
||||
@@ -256,6 +262,17 @@ func normalizeOrder(field string, in []string, max int) ([]string, error) {
|
||||
|
||||
var validThemes = map[string]bool{"light": true, "dark": true, "system": true}
|
||||
var validDateFormats = map[string]bool{"YMD": true, "DMY_NUM": true, "DMY": true, "MDY": true}
|
||||
|
||||
// "auto" reads the clock the way the chosen region writes it, which is what
|
||||
// the app did before there was a setting; the other two say it outright, for
|
||||
// the people whose region and habit disagree.
|
||||
var validTimeFormats = map[string]bool{"auto": true, "24": true, "12": true}
|
||||
|
||||
// Which day a week is drawn as starting on. "auto" is the chosen region's own
|
||||
// convention — Monday across most of Europe, Sunday in the US — and is what
|
||||
// every weekday row read before there was a setting; the other two say it
|
||||
// outright, for the people whose region and habit disagree.
|
||||
var validWeekStarts = map[string]bool{"auto": true, "monday": true, "sunday": true}
|
||||
var validFontSizes = map[string]bool{"small": true, "medium": true, "large": true}
|
||||
|
||||
// Kept in step with the users.currency select options in setup-pocketbase.mjs:
|
||||
@@ -318,6 +335,20 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
payload["date_format"] = *in.DateFormat
|
||||
}
|
||||
if in.TimeFormat != nil {
|
||||
if !validTimeFormats[*in.TimeFormat] {
|
||||
writeError(w, http.StatusBadRequest, "timeFormat must be auto, 24, or 12")
|
||||
return
|
||||
}
|
||||
payload["time_format"] = *in.TimeFormat
|
||||
}
|
||||
if in.WeekStart != nil {
|
||||
if !validWeekStarts[*in.WeekStart] {
|
||||
writeError(w, http.StatusBadRequest, "weekStart must be auto, monday, or sunday")
|
||||
return
|
||||
}
|
||||
payload["week_start"] = *in.WeekStart
|
||||
}
|
||||
if in.Currency != nil {
|
||||
if !validCurrencies[*in.Currency] {
|
||||
writeError(w, http.StatusBadRequest, "currency must be a supported ISO 4217 code")
|
||||
|
||||
@@ -159,6 +159,7 @@ const (
|
||||
colDocuments = "car_documents"
|
||||
colReminders = "reminders"
|
||||
colHomeChargers = "home_chargers"
|
||||
colChargingTasks = "charging_tasks"
|
||||
colControlAudit = "control_audit"
|
||||
colAppSettings = "app_settings"
|
||||
)
|
||||
@@ -182,6 +183,10 @@ type Server struct {
|
||||
ocpp *ocpp.CSMS
|
||||
control *controlIndex // token -> owning user/charger for the /ocpp endpoint
|
||||
ctlRL *rateLimiter // per user+charger control-command rate limit
|
||||
|
||||
// schedulerStop ends the ticker that fires the home-charger scheduler's
|
||||
// tasks, started by StartScheduler (see chargingtasks_run.go).
|
||||
schedulerStop context.CancelFunc
|
||||
}
|
||||
|
||||
// New constructs a Server around an already-built PocketBase client.
|
||||
@@ -285,6 +290,9 @@ func (s *Server) Stop(ctx context.Context) {
|
||||
if s.pluginsStop != nil {
|
||||
s.pluginsStop()
|
||||
}
|
||||
if s.schedulerStop != nil {
|
||||
s.schedulerStop()
|
||||
}
|
||||
s.ocpp.Shutdown(ctx)
|
||||
s.plugins.Shutdown(ctx)
|
||||
}
|
||||
@@ -488,6 +496,14 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("PATCH /api/home-chargers/{id}", s.updateHomeCharger)
|
||||
mux.HandleFunc("DELETE /api/home-chargers/{id}", s.deleteHomeCharger)
|
||||
|
||||
// The home-charger scheduler: one list of charging tasks per user, covering
|
||||
// every charger they own. See chargingtasks.go.
|
||||
mux.HandleFunc("GET /api/charging-tasks", s.listChargingTasks)
|
||||
mux.HandleFunc("POST /api/charging-tasks", s.createChargingTask)
|
||||
mux.HandleFunc("PATCH /api/charging-tasks/{id}", s.updateChargingTask)
|
||||
mux.HandleFunc("DELETE /api/charging-tasks/{id}", s.deleteChargingTask)
|
||||
mux.HandleFunc("POST /api/charging-tasks/{id}/steps/{step}/run", s.runChargingStepNow)
|
||||
|
||||
// Cars + sharing.
|
||||
mux.HandleFunc("GET /api/cars", s.listCars)
|
||||
mux.HandleFunc("POST /api/cars", s.createCar)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,12 +225,48 @@ var collectionsSchema = map[string][]fieldDef{
|
||||
// audit trail's.
|
||||
fAutodate("created", true, false),
|
||||
},
|
||||
// The home-charger scheduler: the user's own list of charging tasks, one list
|
||||
// covering every charger they own. The charger's own cloud schedule holds one
|
||||
// window per box; this holds as many tasks as they like, each naming its own
|
||||
// chargers, days and action. Run by the ticker in internal/api/chargingtasks_run.go.
|
||||
"charging_tasks": {
|
||||
fText("name", true),
|
||||
// The home_chargers rows this task acts on. Stored as a list of ids rather
|
||||
// than a relation because empty has to mean "every charger I own" — a
|
||||
// standing wish that keeps covering chargers imported later — and a
|
||||
// multi-relation would have to be rewritten on every import to say it.
|
||||
fJSON("chargers", 2000),
|
||||
// The flow: [{action, amps, time}, …] in the order it runs. A whole
|
||||
// charging window is one task rather than the two that would otherwise
|
||||
// open and close it, so it is named once and switched off once. Times are
|
||||
// 24-hour "HH:MM" read in the zone below — the server's clock is not the
|
||||
// one the user set 23:00 by. Validated in internal/api/chargingtasks.go.
|
||||
fJSON("steps", 4000),
|
||||
fText("zone", false),
|
||||
fJSON("days", 200), // 0=Sunday … 6=Saturday; empty means every day
|
||||
fBool("enabled"),
|
||||
// The outcome of the last firing, so a task that has been failing quietly
|
||||
// says so in the list. last_run is also the guard against firing twice in
|
||||
// the same minute.
|
||||
fText("last_run", false), // RFC3339, UTC
|
||||
fText("last_result", false),
|
||||
// Owner. Non-cascading, like a charger's.
|
||||
fRelation("owner", "users", false, false),
|
||||
fAutodate("created", true, false),
|
||||
},
|
||||
// Custom fields layered onto the built-in "users" auth collection.
|
||||
"users": {
|
||||
fText("bio", false),
|
||||
fSelect("theme", []string{"light", "dark", "system"}, false),
|
||||
fText("locale", false),
|
||||
fSelect("date_format", []string{"YMD", "DMY_NUM", "DMY", "MDY"}, false),
|
||||
// "auto" is the region's own convention, which is what every clock in the
|
||||
// app read before this field existed.
|
||||
fSelect("time_format", []string{"auto", "24", "12"}, false),
|
||||
// The day a week is drawn as starting on, wherever weekdays are laid out
|
||||
// in a row. "auto" is the region's own convention, which is what the
|
||||
// scheduler's day picker read before this field existed.
|
||||
fSelect("week_start", []string{"auto", "monday", "sunday"}, false),
|
||||
fSelect("currency", []string{
|
||||
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
|
||||
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
|
||||
@@ -277,6 +313,7 @@ var createOrder = []string{
|
||||
"reminders",
|
||||
"control_audit",
|
||||
"home_chargers",
|
||||
"charging_tasks",
|
||||
}
|
||||
|
||||
// reconcileOrder additionally includes "users" so its custom fields (role,
|
||||
@@ -297,6 +334,7 @@ var reconcileOrder = []string{
|
||||
"reminders",
|
||||
"control_audit",
|
||||
"home_chargers",
|
||||
"charging_tasks",
|
||||
}
|
||||
|
||||
// indexes are extra SQL indexes applied at collection-create time.
|
||||
@@ -313,6 +351,9 @@ var indexes = map[string][]string{
|
||||
// A charger is looked up by its owner, and by serial when checking whether
|
||||
// the account it came from has already been imported.
|
||||
"home_chargers": {"CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"},
|
||||
// The runner sweeps every enabled task on every tick, and the page reads one
|
||||
// owner's; both go through these two columns.
|
||||
"charging_tasks": {"CREATE INDEX `idx_charging_tasks_owner_enabled` ON `charging_tasks` (`owner`, `enabled`)"},
|
||||
"control_audit": {
|
||||
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
|
||||
"CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,59 @@ type HomeCharger struct {
|
||||
Created string `json:"created,omitempty"`
|
||||
}
|
||||
|
||||
// ChargingStep is one command in a task's flow: what to do, and at what time
|
||||
// of day. A step is the smallest thing the scheduler sends.
|
||||
type ChargingStep struct {
|
||||
// start, stop, limit (to Amps) or boost.
|
||||
Action string `json:"action"`
|
||||
Amps float64 `json:"amps,omitempty"`
|
||||
|
||||
// A 24-hour "HH:MM", read in the task's zone.
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// ChargingTask is one entry in the home-charger scheduler: a flow of steps, the
|
||||
// days it repeats on, and the chargers it acts on. It belongs to the person,
|
||||
// like the chargers themselves — one list covering every charger they own,
|
||||
// rather than a separate schedule inside each one.
|
||||
//
|
||||
// The charger's own cloud schedule can only say "charge between these hours,
|
||||
// every day, on this one box". This says "start at 23:00, cap to 10 A at 01:00,
|
||||
// stop at 06:30 — on weeknights, on these two chargers", as one named thing that
|
||||
// is switched on and off as one.
|
||||
type ChargingTask struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"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.
|
||||
Chargers []string `json:"chargers"`
|
||||
|
||||
// The flow, in the order it runs. A whole charging window lives in one task
|
||||
// rather than in the two that used to open and close it: it is named once,
|
||||
// switched off once, and reads as the one intention it is.
|
||||
Steps []ChargingStep `json:"steps"`
|
||||
|
||||
// The IANA zone the steps' times are read in — the one the browser was in
|
||||
// when the task was written. The server's own clock is not the one the user
|
||||
// set 23:00 by, and a laptop that travels must not move the schedule.
|
||||
Zone string `json:"zone,omitempty"`
|
||||
|
||||
// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day.
|
||||
Days []int `json:"days"`
|
||||
|
||||
Enabled bool `json:"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.
|
||||
LastRun string `json:"lastRun,omitempty"` // RFC3339, UTC
|
||||
LastResult string `json:"lastResult,omitempty"`
|
||||
|
||||
Created string `json:"created,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceRecord is one row of the Service log for a car.
|
||||
type ServiceRecord struct {
|
||||
ID string `json:"id"`
|
||||
@@ -476,9 +529,13 @@ type User struct {
|
||||
Theme string `json:"theme"` // light | dark | system
|
||||
Locale string `json:"locale"` // e.g. "en-US"
|
||||
DateFormat string `json:"dateFormat"` // YMD | DMY | MDY
|
||||
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
|
||||
FontSize string `json:"fontSize"` // small | medium | large
|
||||
Role string `json:"role"` // user | admin
|
||||
TimeFormat string `json:"timeFormat"` // auto (the region's own) | 24 | 12
|
||||
// The day a week is drawn as starting on, wherever a client lays weekdays
|
||||
// out in a row — the scheduler's day picker today.
|
||||
WeekStart string `json:"weekStart"` // auto (the region's own) | monday | sunday
|
||||
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
|
||||
FontSize string `json:"fontSize"` // small | medium | large
|
||||
Role string `json:"role"` // user | admin
|
||||
|
||||
// DragLocked holds every arrangement on this account's pages still: the
|
||||
// garage, a car's tabs, its Information rows, the provider's readings. A
|
||||
|
||||
@@ -6,12 +6,14 @@ package pb
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -29,6 +31,7 @@ type Client struct {
|
||||
email string
|
||||
password string
|
||||
token string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
func New(baseURL, email, password string) *Client {
|
||||
@@ -69,6 +72,7 @@ func (c *Client) Reconfigure(baseURL, email, password string) {
|
||||
c.email = email
|
||||
c.password = password
|
||||
c.token = ""
|
||||
c.tokenExp = time.Time{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -120,6 +124,7 @@ func (c *Client) Authenticate(ctx context.Context) error {
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.token = out.Token
|
||||
c.tokenExp = jwtExpiry(out.Token)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -134,8 +139,46 @@ func (c *Client) currentToken() string {
|
||||
return c.token
|
||||
}
|
||||
|
||||
// ensureToken acquires a superuser token when none is cached yet, so that a
|
||||
// superuser call never goes out unauthenticated.
|
||||
// tokenSkew is how long before its stated expiry a cached token is treated as
|
||||
// spent. It covers clock drift between this server and PocketBase, and the
|
||||
// flight time of a request that passes the check and then arrives just late.
|
||||
const tokenSkew = 60 * time.Second
|
||||
|
||||
// tokenLive reports whether the cached token can still be used. A token with no
|
||||
// readable expiry is taken at face value — the 401 retry remains the backstop.
|
||||
func (c *Client) tokenLive() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if c.token == "" {
|
||||
return false
|
||||
}
|
||||
return c.tokenExp.IsZero() || time.Now().Add(tokenSkew).Before(c.tokenExp)
|
||||
}
|
||||
|
||||
// jwtExpiry reads the exp claim out of a PocketBase auth token. The signature is
|
||||
// PocketBase's business — this only needs the expiry the server itself stamped,
|
||||
// so the payload is decoded without verification. Anything unreadable comes back
|
||||
// as the zero time, which tokenLive treats as "no expiry known".
|
||||
func jwtExpiry(token string) time.Time {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return time.Time{}
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
var claims struct {
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &claims); err != nil || claims.Exp == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(claims.Exp, 0)
|
||||
}
|
||||
|
||||
// ensureToken acquires a superuser token when the cached one is missing or
|
||||
// spent, so that a superuser call never goes out unauthenticated.
|
||||
//
|
||||
// It matters because PocketBase answers a record read it will not allow with
|
||||
// 404, not 401 — it hides the record rather than refusing the credentials. So a
|
||||
@@ -149,8 +192,18 @@ func (c *Client) currentToken() string {
|
||||
// With no service account configured there is nothing to acquire, so the call
|
||||
// proceeds as before — the endpoints that need superuser access answer 503 on
|
||||
// their own.
|
||||
//
|
||||
// Expiry is checked here rather than left to the 401 retry below, because that
|
||||
// retry never fires for this client: PocketBase does not reject a stale token on
|
||||
// a record call, it ignores the header and serves the request as a guest. The
|
||||
// collection rules then answer instead of the transport — a superuser-only
|
||||
// collection 403s, a rule-guarded record 404s, and a rule-filtered list comes
|
||||
// back 200 with nothing in it. None of those are a 401, so a server whose token
|
||||
// has lapsed keeps sending it and keeps being treated as a stranger to its own
|
||||
// database until it is restarted. Superuser tokens are long-lived, which only
|
||||
// means the failure waits weeks and then arrives as "the app forgot my account".
|
||||
func (c *Client) ensureToken(ctx context.Context) error {
|
||||
if c.currentToken() != "" || !c.Configured() {
|
||||
if c.tokenLive() || !c.Configured() {
|
||||
return nil
|
||||
}
|
||||
return c.Authenticate(ctx)
|
||||
|
||||
@@ -2,11 +2,13 @@ package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PocketBase hides a record it will not let you read behind a 404 rather than a
|
||||
@@ -31,9 +33,14 @@ type fakePB struct {
|
||||
email string
|
||||
password string
|
||||
|
||||
// When set, tokens are minted as JWTs carrying this lifetime in their exp
|
||||
// claim, the way PocketBase issues them. Zero keeps the opaque test token.
|
||||
jwtTTL time.Duration
|
||||
|
||||
authCalls int
|
||||
lastIdentity string
|
||||
guestAttempts int // record reads that arrived without the superuser token
|
||||
issued string // the token most recently handed out
|
||||
guestAttempts int // record reads that arrived without the superuser token
|
||||
}
|
||||
|
||||
func (f *fakePB) handler() http.Handler {
|
||||
@@ -51,11 +58,21 @@ func (f *fakePB) handler() http.Handler {
|
||||
writeTestJSON(w, 400, map[string]any{"message": "Failed to authenticate."})
|
||||
return
|
||||
}
|
||||
writeTestJSON(w, 200, map[string]any{"token": fakeSvcToken})
|
||||
token := fakeSvcToken
|
||||
if f.jwtTTL > 0 {
|
||||
token = testJWT(time.Now().Add(f.jwtTTL))
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.issued = token
|
||||
f.mu.Unlock()
|
||||
writeTestJSON(w, 200, map[string]any{"token": token})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/collections/users/records/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != fakeSvcToken {
|
||||
f.mu.Lock()
|
||||
live := f.issued
|
||||
f.mu.Unlock()
|
||||
if got := r.Header.Get("Authorization"); got == "" || got != live {
|
||||
f.mu.Lock()
|
||||
f.guestAttempts++
|
||||
f.mu.Unlock()
|
||||
@@ -190,3 +207,101 @@ func TestNoServiceAccountSkipsAuthentication(t *testing.T) {
|
||||
t.Errorf("attempted %d sign-in(s) with no credentials, want 0", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// testJWT builds a token shaped like PocketBase's: three base64url segments,
|
||||
// the middle one carrying the exp claim. Only that claim is ever read, so the
|
||||
// header and signature are filler.
|
||||
func testJWT(exp time.Time) string {
|
||||
enc := func(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
return enc(map[string]string{"alg": "HS256", "typ": "JWT"}) + "." +
|
||||
enc(map[string]int64{"exp": exp.Unix()}) + ".sig"
|
||||
}
|
||||
|
||||
func TestJWTExpiry(t *testing.T) {
|
||||
want := time.Now().Add(time.Hour).Truncate(time.Second)
|
||||
if got := jwtExpiry(testJWT(want)); !got.Equal(want) {
|
||||
t.Errorf("jwtExpiry = %v, want %v", got, want)
|
||||
}
|
||||
// An opaque (non-JWT) token has no readable expiry, and must not be mistaken
|
||||
// for one that expired at the zero time — tokenLive takes it at face value.
|
||||
for _, tok := range []string{"", "opaque", "a.b", "a.!!.c", "a." + base64.RawURLEncoding.EncodeToString([]byte("{}")) + ".c"} {
|
||||
if got := jwtExpiry(tok); !got.IsZero() {
|
||||
t.Errorf("jwtExpiry(%q) = %v, want zero", tok, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The failure this whole mechanism exists for: a superuser token that has run
|
||||
// out. PocketBase does not answer 401 for one — it ignores the header and serves
|
||||
// the request as a guest, so the 401 retry never fires and the client would go
|
||||
// on presenting a dead token forever. The expiry check has to catch it first.
|
||||
func TestExpiredTokenIsRenewedBeforeUse(t *testing.T) {
|
||||
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
|
||||
c := New(newFakePB(t, f), f.email, f.password)
|
||||
|
||||
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||
t.Fatalf("GetOne: %v", err)
|
||||
}
|
||||
|
||||
// Age the cached token past its expiry, as an uptime longer than the token's
|
||||
// lifetime would.
|
||||
c.mu.Lock()
|
||||
c.tokenExp = time.Now().Add(-time.Minute)
|
||||
c.mu.Unlock()
|
||||
|
||||
var rec struct{ ID string }
|
||||
if err := c.GetOne(context.Background(), "users", fakeRecordID, &rec); err != nil {
|
||||
t.Fatalf("GetOne with an expired token: %v", err)
|
||||
}
|
||||
if rec.ID != fakeRecordID {
|
||||
t.Fatalf("record id = %q, want %q", rec.ID, fakeRecordID)
|
||||
}
|
||||
|
||||
auth, guest := f.counts()
|
||||
if guest != 0 {
|
||||
t.Errorf("%d record read(s) went out on a dead token, want 0", guest)
|
||||
}
|
||||
if auth != 2 {
|
||||
t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// A token close enough to its expiry that it could lapse mid-flight is renewed
|
||||
// rather than spent, so a call cannot land just after the token dies.
|
||||
func TestTokenNearingExpiryIsRenewed(t *testing.T) {
|
||||
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
|
||||
c := New(newFakePB(t, f), f.email, f.password)
|
||||
|
||||
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||
t.Fatalf("GetOne: %v", err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.tokenExp = time.Now().Add(tokenSkew / 2)
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||
t.Fatalf("GetOne near expiry: %v", err)
|
||||
}
|
||||
if auth, _ := f.counts(); auth != 2 {
|
||||
t.Errorf("authenticated %d time(s), want 2 (startup + renewal)", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// A token with a real lifetime still ahead of it is reused — the expiry check
|
||||
// must not turn every call into a fresh sign-in.
|
||||
func TestLiveJWTIsReused(t *testing.T) {
|
||||
f := &fakePB{email: "admin@test.local", password: "pw", jwtTTL: time.Hour}
|
||||
c := New(newFakePB(t, f), f.email, f.password)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
|
||||
t.Fatalf("GetOne #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if auth, _ := f.counts(); auth != 1 {
|
||||
t.Errorf("authenticated %d time(s), want 1", auth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +334,19 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
{Value: "own", Label: "Own CSMS (full control)"},
|
||||
{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
|
||||
}},
|
||||
// controlModesDisabled hides modes from the layers *below* this one. It
|
||||
// is how a superadmin takes a mode that is broken or unwanted in this
|
||||
// deployment (OCPP, say) out of every organization's and user's picker
|
||||
// without touching the mode this layer itself runs. Organizations carry
|
||||
// the same field for their own users; see integrations_ankersolix.go.
|
||||
{Key: "controlModesDisabled", Label: "Hidden control modes", Type: "multiselect",
|
||||
Help: "Control modes to hide from organizations and users. A hidden mode disappears from their picker and stops taking effect for them; the mode chosen above, which is this layer's own, is unaffected. Off (monitoring only) can never be hidden — it is what a charger falls back to.",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "mqtt", Label: "Anker cloud (works anywhere)"},
|
||||
{Value: "modbus", Label: "Modbus TCP (local network)"},
|
||||
{Value: "own", Label: "Own CSMS (full control)"},
|
||||
{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,31 @@ func TestDescriptor(t *testing.T) {
|
||||
t.Errorf("controlMode is missing option %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// controlModesDisabled is the superadmin's hide-list: which of the modes the
|
||||
// organizations and users below are offered at all. off is not among them —
|
||||
// monitoring only is the fallback, so no layer may take it away.
|
||||
hide, ok := fields["controlModesDisabled"]
|
||||
if !ok {
|
||||
t.Fatal("controlModesDisabled config field should be present")
|
||||
}
|
||||
if hide.Type != "multiselect" {
|
||||
t.Errorf("controlModesDisabled type = %q, want multiselect", hide.Type)
|
||||
}
|
||||
hideable := map[string]bool{"mqtt": false, "modbus": false, "own": false, "proxy": false}
|
||||
for _, o := range hide.Options {
|
||||
if o.Value == "off" {
|
||||
t.Error("off must not be hideable — it is what a charger falls back to")
|
||||
}
|
||||
if _, known := hideable[o.Value]; known {
|
||||
hideable[o.Value] = true
|
||||
}
|
||||
}
|
||||
for v, seen := range hideable {
|
||||
if !seen {
|
||||
t.Errorf("controlModesDisabled is missing option %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistered(t *testing.T) {
|
||||
|
||||
@@ -97,7 +97,9 @@ const (
|
||||
// asks for it again; settingsWait is how long that read then waits for the
|
||||
// answer, and statusReqTries how many unanswered requests it takes before a
|
||||
// charger is treated as one whose firmware ignores the message — after which
|
||||
// the request still goes out, but no read pays the wait for it.
|
||||
// the request still goes out, but no read pays the wait for it. That verdict
|
||||
// is undone by the charger answering (see ingest): it describes a spell of
|
||||
// silence, not a permanent property of the firmware.
|
||||
settingsMaxAge = 10 * time.Minute
|
||||
settingsWait = 4 * time.Second
|
||||
statusReqTries = 3
|
||||
@@ -558,6 +560,12 @@ func (c *mqttConn) ingest(msg mqtt.Message) {
|
||||
st.telemetryAt = now
|
||||
case evMessages[msgType] != nil:
|
||||
st.settingsAt = now
|
||||
// It does report its settings after all, so whatever made it look like a
|
||||
// firmware that ignores the request has passed. Without this the verdict
|
||||
// was permanent — the counter only ever went up — and a charger that
|
||||
// missed three requests early on was never waited for again, however
|
||||
// freely it answered afterwards.
|
||||
st.statusReqMisses = 0
|
||||
}
|
||||
c.wakeLocked()
|
||||
}
|
||||
|
||||
@@ -265,3 +265,27 @@ func TestClientIDDoesNotCollideWithTheApp(t *testing.T) {
|
||||
t.Errorf("client id %q does not fall back to the user id", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A charger that goes quiet long enough to be written off is not written off for
|
||||
// good. The miss counter decides whether a status read waits for the settings
|
||||
// frame at all, so a counter that only ever rose meant one early patch of
|
||||
// silence cost every later read its settings — which is the half the settings
|
||||
// card is drawn from.
|
||||
func TestAnsweringClearsTheStatusRequestMisses(t *testing.T) {
|
||||
c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})}
|
||||
|
||||
// Three unanswered requests: the reads stop paying the wait.
|
||||
c.ingest(envelope(t, "SN1", buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02))))
|
||||
for i := 0; i < statusReqTries; i++ {
|
||||
c.noteStatusMiss("SN1")
|
||||
}
|
||||
if c.statusReqAnswered("SN1") {
|
||||
t.Fatalf("after %d misses the charger should not be waited for", statusReqTries)
|
||||
}
|
||||
|
||||
// Then it answers one.
|
||||
c.ingest(envelope(t, "SN1", buildInbound(t, msgEVParams, field(0xa8, typeInt16LE, 0x40, 0x01))))
|
||||
if !c.statusReqAnswered("SN1") {
|
||||
t.Error("a charger that reported its settings is still being written off")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,9 +224,22 @@ func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, er
|
||||
// carrying an Anker bug, so a firmware that ignores it must not tax every read
|
||||
// with the same wait forever.
|
||||
if askedSettings && conn.statusReqAnswered(sn) {
|
||||
// A charger that has never reported its settings is a different case from
|
||||
// one whose settings have merely gone stale. Stale has something to fall
|
||||
// back on — the values are still there, and a read that misses costs the
|
||||
// caller nothing but their age. Never-reported has nothing: the settings
|
||||
// are absent from the answer entirely, and the card that reads them draws
|
||||
// almost nothing. So the first ones are given the same wait a settings
|
||||
// write gives them rather than the short one that only has to catch a
|
||||
// refresh. It is still bounded by statusReqTries, so a charger that truly
|
||||
// never answers costs that wait three times and then stops being asked to.
|
||||
wait := settingsWait
|
||||
if settingsAt.IsZero() {
|
||||
wait = statusWait
|
||||
}
|
||||
settled, werr := conn.waitFor(ctx, sn, func(st *deviceState) bool {
|
||||
return st.settingsAt.After(cutoff)
|
||||
}, settingsWait)
|
||||
}, wait)
|
||||
if werr == nil && !settled {
|
||||
conn.noteStatusMiss(sn)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ const (
|
||||
StatusDown = "down"
|
||||
)
|
||||
|
||||
// SelectOption is one choice for a ConfigField of Type "select".
|
||||
// SelectOption is one choice for a ConfigField of Type "select" (pick one) or
|
||||
// "multiselect" (pick any number; stored as a comma-separated list of values).
|
||||
type SelectOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
@@ -55,12 +56,12 @@ type SelectOption struct {
|
||||
type ConfigField struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"` // "text" | "password" | "number" | "select"
|
||||
Type string `json:"type"` // "text" | "password" | "number" | "select" | "multiselect"
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"` // never echoed back to clients in clear
|
||||
Help string `json:"help,omitempty"`
|
||||
Default string `json:"default,omitempty"` // effective default when unset
|
||||
Options []SelectOption `json:"options,omitempty"` // for Type "select"
|
||||
Options []SelectOption `json:"options,omitempty"` // for Type "select" and "multiselect"
|
||||
}
|
||||
|
||||
// Capability is one operation a plugin exposes. It maps a stable id to the
|
||||
|
||||
@@ -62,6 +62,31 @@ function expand(p) {
|
||||
open.value = p.name;
|
||||
}
|
||||
|
||||
// A multiselect field is a comma-separated list of option values in the same
|
||||
// flat string map every other field uses, so the PUT body stays unchanged.
|
||||
function multiValues(name, key) {
|
||||
return String(drafts[name]?.[key] ?? "")
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
function hasMulti(name, key, value) {
|
||||
return multiValues(name, key).includes(value);
|
||||
}
|
||||
function toggleMulti(name, key, value, on) {
|
||||
const set = new Set(multiValues(name, key));
|
||||
if (on) set.add(value);
|
||||
else set.delete(value);
|
||||
// Keep the declared option order rather than click order, so the stored value
|
||||
// is stable across edits.
|
||||
const p = plugins.value.find((x) => x.name === name);
|
||||
const order = (p?.configFields || []).find((f) => f.key === key)?.options || [];
|
||||
drafts[name][key] = order
|
||||
.map((o) => o.value)
|
||||
.filter((v) => set.has(v))
|
||||
.join(",");
|
||||
}
|
||||
|
||||
async function save(p, enabled) {
|
||||
busy.value = true;
|
||||
rowNotice[p.name] = "";
|
||||
@@ -224,7 +249,25 @@ const healthClass = (s) =>
|
||||
<label class="dh-label">
|
||||
{{ f.label || f.key }}<span v-if="f.required" class="text-danger"> *</span>
|
||||
</label>
|
||||
<select v-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select">
|
||||
<!-- multiselect: any number of the declared options, stored as a
|
||||
comma-separated list. Nothing checked means the field imposes
|
||||
nothing, which is the unset state for a select too. -->
|
||||
<div v-if="f.type === 'multiselect'" class="flex flex-col gap-1.5 pt-1">
|
||||
<label
|
||||
v-for="o in f.options || []"
|
||||
:key="o.value"
|
||||
class="flex items-center gap-2 text-sm text-body"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="dh-checkbox"
|
||||
:checked="hasMulti(p.name, f.key, o.value)"
|
||||
@change="toggleMulti(p.name, f.key, o.value, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ o.label || o.value }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<select v-else-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select">
|
||||
<!-- A non-required select can be left unset (empty), so the global
|
||||
layer abstains and lower layers (org / user) may choose. -->
|
||||
<option v-if="!f.required" value="">{{ t("plugins.notSet") }}</option>
|
||||
|
||||
@@ -293,6 +293,20 @@ body {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
/* Checkbox for multiselect config fields. accent-color keeps the native control
|
||||
(and its keyboard behaviour) while tinting it to the panel's accent. */
|
||||
.dh-checkbox {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: none;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dh-checkbox:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.dh-label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
|
||||
@@ -462,6 +462,31 @@ const DESIRED = {
|
||||
// defined through the API, so it is declared here like the audit trail's.
|
||||
F.autodate("created", true, false),
|
||||
],
|
||||
// The home-charger scheduler: a user's own list of charging tasks, one list
|
||||
// covering every charger they own. The charger's own cloud schedule holds one
|
||||
// window per box; this holds as many tasks as they like, each naming its own
|
||||
// chargers, days and action. Run by the ticker in the API Server
|
||||
// (internal/api/chargingtasks_run.go).
|
||||
charging_tasks: [
|
||||
F.text("name", true),
|
||||
// The home_chargers rows this task acts on. A list of ids rather than a
|
||||
// relation because empty has to mean "every charger I own" — a standing wish
|
||||
// that keeps covering chargers imported later.
|
||||
F.json("chargers", 2000),
|
||||
// The flow: [{action, amps, time}, …] in the order it runs. A whole charging
|
||||
// window is one task rather than the two that would otherwise open and close
|
||||
// it, so it is named once and switched off once. Times are 24-hour "HH:MM"
|
||||
// read in the zone below.
|
||||
F.json("steps", 4000),
|
||||
F.text("zone"),
|
||||
F.json("days", 200), // 0=Sunday … 6=Saturday; empty means every day
|
||||
F.bool("enabled"),
|
||||
F.text("last_run"), // RFC3339, UTC — also the guard against a double firing
|
||||
F.text("last_result"),
|
||||
// Owner. Non-cascading, like a charger's.
|
||||
F.relation("owner", "users", false, false),
|
||||
F.autodate("created", true, false),
|
||||
],
|
||||
// Server-wide settings as a single record, keyed "global". Today it holds
|
||||
// pluginSettings: the top (L1) layer of the integration cascade — every
|
||||
// plugin's enable state, its global config, and the registration of any
|
||||
@@ -490,6 +515,13 @@ const DESIRED = {
|
||||
F.select("theme", ["light", "dark", "system"]),
|
||||
F.text("locale"),
|
||||
F.select("date_format", ["YMD", "DMY_NUM", "DMY", "MDY"]),
|
||||
// "auto" is the region's own convention. Kept in step with
|
||||
// validTimeFormats in internal/api/me.go.
|
||||
F.select("time_format", ["auto", "24", "12"]),
|
||||
// The day a week is drawn as starting on, wherever a client lays weekdays
|
||||
// out in a row. "auto" is the region's own convention. Kept in step with
|
||||
// validWeekStarts in internal/api/me.go.
|
||||
F.select("week_start", ["auto", "monday", "sunday"]),
|
||||
// European currencies plus the non-European ones the panel already offered.
|
||||
// Kept in step with validCurrencies in internal/api/me.go and CURRENCY_CODES
|
||||
// in the web app's Settings.vue.
|
||||
@@ -546,6 +578,11 @@ const INDEXES = {
|
||||
// A charger is looked up by its owner, and by serial when checking whether the
|
||||
// account it came from has already been imported.
|
||||
home_chargers: ["CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"],
|
||||
// The runner sweeps every enabled task on every tick, and the page reads one
|
||||
// owner's; both go through these two columns.
|
||||
charging_tasks: [
|
||||
"CREATE INDEX `idx_charging_tasks_owner_enabled` ON `charging_tasks` (`owner`, `enabled`)",
|
||||
],
|
||||
// Audit is queried "this charger's events, newest first" and "this user's events".
|
||||
control_audit: [
|
||||
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
|
||||
@@ -583,6 +620,7 @@ async function main() {
|
||||
"reminders",
|
||||
"control_audit",
|
||||
"home_chargers",
|
||||
"charging_tasks",
|
||||
]) {
|
||||
if (collections.some((c) => c.name === name)) continue;
|
||||
await createCollection(token, name, DESIRED[name], format, idByName);
|
||||
@@ -611,6 +649,7 @@ async function main() {
|
||||
"reminders",
|
||||
"control_audit",
|
||||
"home_chargers",
|
||||
"charging_tasks",
|
||||
]) {
|
||||
await reconcileFields(token, name, DESIRED[name], format, idByName);
|
||||
}
|
||||
@@ -619,7 +658,8 @@ async function main() {
|
||||
"\nDone. Collections ready: app_settings, organizations, users, cars,\n" +
|
||||
"service_records,\n" +
|
||||
"technical_checks, parts, car_shares, fuel_entries, charging_sessions,\n" +
|
||||
"maintenance_entries, car_documents, reminders, control_audit, home_chargers.",
|
||||
"maintenance_entries, car_documents, reminders, control_audit, home_chargers,\n" +
|
||||
"charging_tasks.",
|
||||
);
|
||||
console.log(
|
||||
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +
|
||||
|
||||
@@ -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",
|
||||
@@ -535,6 +661,8 @@
|
||||
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
|
||||
"controlMode": "Styringstilstand",
|
||||
"controlModeHint": "Hvordan DriverVault styrer laderen.",
|
||||
"controlModesHidden": "Skjul for dine brugere",
|
||||
"controlModesHiddenHint": "Tilstande du markerer her forsvinder fra dine brugeres liste og træder ikke længere i kraft for dem. Kun overvågning er altid tilgængelig.",
|
||||
"controlOff": "Fra (kun overvågning)",
|
||||
"controlOwn": "Eget CSMS (fuld styring)",
|
||||
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
|
||||
@@ -1167,6 +1295,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": {
|
||||
@@ -415,6 +531,8 @@
|
||||
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
|
||||
"controlMode": "Control mode",
|
||||
"controlModeHint": "How DriverVault controls the charger.",
|
||||
"controlModesHidden": "Hide from your users",
|
||||
"controlModesHiddenHint": "Modes you tick here disappear from your users' picker and stop taking effect for them. Monitoring only is always available.",
|
||||
"controlOff": "Off (monitoring only)",
|
||||
"controlOwn": "Own CSMS (full control)",
|
||||
"controlProxy": "Proxy CSMS (relay + control)",
|
||||
@@ -528,7 +646,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 +1295,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",
|
||||
@@ -541,6 +669,8 @@
|
||||
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
|
||||
"controlMode": "Tryb sterowania",
|
||||
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką.",
|
||||
"controlModesHidden": "Ukryj przed użytkownikami",
|
||||
"controlModesHiddenHint": "Zaznaczone tryby znikają z listy Twoich użytkowników i przestają dla nich działać. Tylko monitorowanie jest zawsze dostępne.",
|
||||
"controlOff": "Wyłączone (tylko monitorowanie)",
|
||||
"controlOwn": "Własny CSMS (pełne sterowanie)",
|
||||
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
|
||||
@@ -1185,6 +1315,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.
|
||||
|
||||
+207
-1
@@ -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"]),
|
||||
@@ -988,7 +998,21 @@ class IntegrationScope {
|
||||
final String editableLayer; // "user" | "org"
|
||||
final Map<String, IntegrationField> fields;
|
||||
|
||||
const IntegrationScope({this.editableLayer = "user", this.fields = const {}});
|
||||
/// Anker only. The control modes this scope may still choose — the server has
|
||||
/// already dropped whatever the layers above it hid. Empty for an integration
|
||||
/// that has no such list.
|
||||
final List<String> controlModes;
|
||||
|
||||
/// Anker, org scope only. The modes this organization hides from its own
|
||||
/// users, which its admin edits here.
|
||||
final List<String> controlModesDisabled;
|
||||
|
||||
const IntegrationScope({
|
||||
this.editableLayer = "user",
|
||||
this.fields = const {},
|
||||
this.controlModes = const [],
|
||||
this.controlModesDisabled = const [],
|
||||
});
|
||||
|
||||
factory IntegrationScope.fromJson(Map<String, dynamic> j) {
|
||||
final raw = j["fields"];
|
||||
@@ -1001,6 +1025,8 @@ class IntegrationScope {
|
||||
return IntegrationScope(
|
||||
editableLayer: _asStr(j["editableLayer"]).isEmpty ? "user" : _asStr(j["editableLayer"]),
|
||||
fields: fields,
|
||||
controlModes: _asStrList(j["controlModes"]),
|
||||
controlModesDisabled: _asStrList(j["controlModesDisabled"]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1235,6 +1261,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 +1273,7 @@ class AnkerCharger {
|
||||
this.siteName = "",
|
||||
this.statusDesc = "",
|
||||
this.online,
|
||||
this.imageUrl = "",
|
||||
});
|
||||
|
||||
factory AnkerCharger.fromJson(Map<String, dynamic> j) => AnkerCharger(
|
||||
@@ -1253,6 +1284,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 +1316,180 @@ class AnkerChargerList {
|
||||
}
|
||||
}
|
||||
|
||||
// --- RFID cards (who may start a charge without a phone) --------------------
|
||||
|
||||
/// One card authorised on a charger, as the account holds it. The cloud answers
|
||||
/// with its own field names — alias_name, card_number, create_time — and this is
|
||||
/// the same three read out: a number to delete by, the name it was given, and
|
||||
/// when it was added.
|
||||
class RfidCard {
|
||||
final String number;
|
||||
final String name;
|
||||
final String added; // as the cloud sent it: unix seconds, or ""
|
||||
|
||||
const RfidCard({required this.number, this.name = "", this.added = ""});
|
||||
|
||||
factory RfidCard.fromJson(Map<String, dynamic> j) {
|
||||
final number = _asStr(j["card_number"]).trim();
|
||||
final name = _asStr(j["alias_name"]).trim();
|
||||
return RfidCard(
|
||||
number: number,
|
||||
// A card with no name of its own is still a card somebody holds, and its
|
||||
// number is the only honest thing to call it.
|
||||
name: name.isEmpty ? number : name,
|
||||
added: _asStr(j["create_time"]).trim(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a card write answers with: whether the account holds that card now, and
|
||||
/// the whole list as it stands after the write. Anker documents neither endpoint,
|
||||
/// so a 200 proves nothing on its own — it is the list that says what happened.
|
||||
class RfidCardWrite {
|
||||
final bool present;
|
||||
final List<RfidCard> cards;
|
||||
final String detail;
|
||||
|
||||
const RfidCardWrite({this.present = false, this.cards = const [], this.detail = ""});
|
||||
|
||||
factory RfidCardWrite.fromJson(Map<String, dynamic> j) => RfidCardWrite(
|
||||
present: _asBool(j["present"]),
|
||||
cards: j["cards"] is List
|
||||
? (j["cards"] as List)
|
||||
.whereType<Map>()
|
||||
.map((c) => RfidCard.fromJson(Map<String, dynamic>.from(c)))
|
||||
.where((c) => c.number.isNotEmpty)
|
||||
.toList()
|
||||
: const [],
|
||||
detail: _asStr(j["detail"]),
|
||||
);
|
||||
}
|
||||
|
||||
/// What a scan answers with: the card that was held against the reader, or the
|
||||
/// plain fact that nothing was. A window that closed empty is an answer, not a
|
||||
/// timeout, which is why [tapped] is separate from [card].
|
||||
class RfidScan {
|
||||
final bool tapped;
|
||||
final String card;
|
||||
|
||||
const RfidScan({this.tapped = false, this.card = ""});
|
||||
|
||||
factory RfidScan.fromJson(Map<String, dynamic> j) =>
|
||||
RfidScan(tapped: _asBool(j["tapped"]), card: _asStr(j["card"]).trim());
|
||||
}
|
||||
|
||||
// --- the charging scheduler -------------------------------------------------
|
||||
//
|
||||
// The charger's own cloud schedule can say one thing — "charge between these
|
||||
// hours" — and it says it inside one charger. This is a list, and each entry is
|
||||
// a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30, on these
|
||||
// chargers, on these days. One named thing, switched on and off as one.
|
||||
|
||||
/// One command in a task's flow: what to do, and at what time of day.
|
||||
class ChargingStep {
|
||||
/// start, stop, limit (to [amps]) or boost.
|
||||
final String action;
|
||||
final double amps;
|
||||
|
||||
/// A 24-hour "HH:MM", read in the task's zone.
|
||||
final String time;
|
||||
|
||||
const ChargingStep({required this.action, required this.time, this.amps = 0});
|
||||
|
||||
factory ChargingStep.fromJson(Map<String, dynamic> j) => ChargingStep(
|
||||
action: _asStr(j["action"]),
|
||||
time: _asStr(j["time"]),
|
||||
amps: _asDouble(j["amps"]),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {"action": action, "time": time, "amps": amps};
|
||||
}
|
||||
|
||||
/// One entry in the home-charger scheduler. It belongs to the person, like the
|
||||
/// chargers it acts on — one list covering every charger they own, rather than a
|
||||
/// separate schedule inside each one.
|
||||
class ChargingTask {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// The home-charger records this task acts on. Empty means every charger the
|
||||
/// owner has, including ones imported after the task was written — "all of
|
||||
/// them" is a standing wish, not the list that happened to exist that day.
|
||||
final List<String> chargers;
|
||||
|
||||
/// The flow, in the order it runs.
|
||||
final List<ChargingStep> steps;
|
||||
|
||||
/// The IANA zone the steps' times are read in. The server's own clock is not
|
||||
/// the one the user set 23:00 by.
|
||||
final String zone;
|
||||
|
||||
/// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day.
|
||||
final List<int> days;
|
||||
|
||||
final bool enabled;
|
||||
|
||||
/// What happened the last time a step of it fired, so a task that has been
|
||||
/// failing quietly for a week says so in the list rather than in a log nobody
|
||||
/// reads.
|
||||
final DateTime? lastRun;
|
||||
final String lastResult;
|
||||
|
||||
const ChargingTask({
|
||||
required this.id,
|
||||
this.name = "",
|
||||
this.chargers = const [],
|
||||
this.steps = const [],
|
||||
this.zone = "",
|
||||
this.days = const [],
|
||||
this.enabled = true,
|
||||
this.lastRun,
|
||||
this.lastResult = "",
|
||||
});
|
||||
|
||||
factory ChargingTask.fromJson(Map<String, dynamic> j) => ChargingTask(
|
||||
id: _asStr(j["id"]),
|
||||
name: _asStr(j["name"]),
|
||||
chargers: _asStrList(j["chargers"]),
|
||||
steps: j["steps"] is List
|
||||
? (j["steps"] as List)
|
||||
.whereType<Map>()
|
||||
.map((e) => ChargingStep.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList()
|
||||
: const [],
|
||||
zone: _asStr(j["zone"]),
|
||||
days: j["days"] is List ? (j["days"] as List).map(_asInt).toList() : const [],
|
||||
enabled: _asBool(j["enabled"]),
|
||||
lastRun: j["lastRun"] == null ? null : DateTime.tryParse(_asStr(j["lastRun"]))?.toLocal(),
|
||||
lastResult: _asStr(j["lastResult"]),
|
||||
);
|
||||
|
||||
/// The time of day the task begins — its first step's, which is what the list
|
||||
/// is ordered by, so the evening's task sits below the morning's.
|
||||
String get firstTime => steps.isEmpty ? "99:99" : steps.first.time;
|
||||
|
||||
/// Whether the last firing reached every charger it was aimed at. The server
|
||||
/// words the outcome as the step it fired and then "n of m sent", so every
|
||||
/// charger answering is the only good case; unknown until it has fired once.
|
||||
bool? get lastRunOk {
|
||||
if (lastRun == null) return null;
|
||||
final m = RegExp(r"(\d+) of (\d+) sent$").firstMatch(lastResult);
|
||||
return m != null && m.group(1) == m.group(2);
|
||||
}
|
||||
|
||||
ChargingTask copyWith({bool? enabled, DateTime? lastRun, String? lastResult}) => ChargingTask(
|
||||
id: id,
|
||||
name: name,
|
||||
chargers: chargers,
|
||||
steps: steps,
|
||||
zone: zone,
|
||||
days: days,
|
||||
enabled: enabled ?? this.enabled,
|
||||
lastRun: lastRun ?? this.lastRun,
|
||||
lastResult: lastResult ?? this.lastResult,
|
||||
);
|
||||
}
|
||||
|
||||
// --- home chargers (the user's own wallbox) ---------------------------------
|
||||
//
|
||||
// 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),
|
||||
@@ -1484,20 +1547,42 @@ class _DangerSectionState extends State<_DangerSection> {
|
||||
// second "org" scope to edit organization-wide defaults; superadmins manage the
|
||||
// shared layer in the API Server panel, so here it is read-only.
|
||||
|
||||
enum _FieldType { text, number, password, select }
|
||||
enum _FieldType { text, number, password, select, multiselect }
|
||||
|
||||
/// The control modes the server still offers in a scope, and the ones this
|
||||
/// organization hides from its own users. Named functions rather than closures
|
||||
/// so the field specs below can stay const.
|
||||
List<String> _scopeControlModes(IntegrationScope s) => s.controlModes;
|
||||
List<String> _scopeHiddenControlModes(IntegrationScope s) => s.controlModesDisabled;
|
||||
|
||||
/// Describes one credential field within an integration card.
|
||||
class _FieldSpec {
|
||||
final String key;
|
||||
final String labelKey;
|
||||
final _FieldType type;
|
||||
final List<(String, String)> options; // (value, labelKey) for selects
|
||||
final List<(String, String)> options; // (value, labelKey) for select/multiselect
|
||||
final String? placeholder; // literal placeholder
|
||||
final String? hintKey; // shown below the field when not locked
|
||||
final String defaultValue;
|
||||
final bool showEffectiveWhenLocked; // controlMode isn't secret: show it locked
|
||||
final int? maxLength;
|
||||
|
||||
/// Narrows the declared options to those the server still offers in this
|
||||
/// scope — the control-mode hide-list, which is what makes a mode a superadmin
|
||||
/// switched off disappear from an organization's picker, and one an
|
||||
/// organization switched off disappear from its users'. Null leaves every
|
||||
/// declared option standing.
|
||||
final List<String> Function(IntegrationScope)? scopeOptions;
|
||||
|
||||
/// Where a multiselect reads its current value from — a scope list rather than
|
||||
/// a field, because a hide-list is not a cascaded value: it is this layer's
|
||||
/// own instruction to the layers below.
|
||||
final List<String> Function(IntegrationScope)? scopeValue;
|
||||
|
||||
/// Only shown when editing the organization layer. A user has nobody below
|
||||
/// them, so a hide-list would mean nothing there.
|
||||
final bool orgOnly;
|
||||
|
||||
const _FieldSpec({
|
||||
required this.key,
|
||||
required this.labelKey,
|
||||
@@ -1508,6 +1593,9 @@ class _FieldSpec {
|
||||
this.defaultValue = "",
|
||||
this.showEffectiveWhenLocked = false,
|
||||
this.maxLength,
|
||||
this.scopeOptions,
|
||||
this.scopeValue,
|
||||
this.orgOnly = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1593,6 +1681,7 @@ class _IntegrationsTabState extends State<_IntegrationsTab> {
|
||||
hintKey: "settings.integrations.controlModeHint",
|
||||
defaultValue: "off",
|
||||
showEffectiveWhenLocked: true,
|
||||
scopeOptions: _scopeControlModes,
|
||||
options: [
|
||||
("off", "settings.integrations.controlOff"),
|
||||
("mqtt", "settings.integrations.controlCloud"),
|
||||
@@ -1601,6 +1690,23 @@ class _IntegrationsTabState extends State<_IntegrationsTab> {
|
||||
("proxy", "settings.integrations.controlProxy"),
|
||||
],
|
||||
),
|
||||
// What this organization hides from its own users. Off is absent on
|
||||
// purpose: monitoring only is the fallback, so no layer may take it away.
|
||||
_FieldSpec(
|
||||
key: "controlModesDisabled",
|
||||
labelKey: "settings.integrations.controlModesHidden",
|
||||
type: _FieldType.multiselect,
|
||||
hintKey: "settings.integrations.controlModesHiddenHint",
|
||||
orgOnly: true,
|
||||
scopeOptions: _scopeControlModes,
|
||||
scopeValue: _scopeHiddenControlModes,
|
||||
options: [
|
||||
("mqtt", "settings.integrations.controlCloud"),
|
||||
("modbus", "settings.integrations.controlModbus"),
|
||||
("own", "settings.integrations.controlOwn"),
|
||||
("proxy", "settings.integrations.controlProxy"),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1736,6 +1842,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
|
||||
final Map<String, TextEditingController> _controllers = {};
|
||||
final Map<String, String> _selects = {};
|
||||
final Map<String, Set<String>> _multi = {}; // multiselect fields (hide-lists)
|
||||
|
||||
_IntegrationConfig get _c => widget.config;
|
||||
|
||||
@@ -1743,7 +1850,9 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final f in _c.fields) {
|
||||
if (f.type != _FieldType.select) _controllers[f.key] = TextEditingController();
|
||||
if (f.type != _FieldType.select && f.type != _FieldType.multiselect) {
|
||||
_controllers[f.key] = TextEditingController();
|
||||
}
|
||||
}
|
||||
_load();
|
||||
}
|
||||
@@ -1762,6 +1871,25 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
bool get _readOnly => _view?.isSuperadmin ?? false;
|
||||
IntegrationScope get _scopeData => _view?.scope(_scopeKey) ?? const IntegrationScope();
|
||||
IntegrationField _field(String k) => _scopeData.field(k);
|
||||
|
||||
/// The fields this scope actually shows: an org-only field (a hide-list) is
|
||||
/// out of the user scope, and a field whose options the layers above have all
|
||||
/// hidden has nothing left to offer.
|
||||
List<_FieldSpec> get _shownFields => [
|
||||
for (final f in _c.fields)
|
||||
if ((!f.orgOnly || _editingOrg) && (f.options.isEmpty || _optionsFor(f).isNotEmpty)) f,
|
||||
];
|
||||
|
||||
/// A field's options after the scope's own list has narrowed them.
|
||||
List<(String, String)> _optionsFor(_FieldSpec f) {
|
||||
if (f.scopeOptions == null) return f.options;
|
||||
final allowed = f.scopeOptions!(_scopeData);
|
||||
if (allowed.isEmpty) return f.options;
|
||||
return [
|
||||
for (final o in f.options)
|
||||
if (allowed.contains(o.$1)) o,
|
||||
];
|
||||
}
|
||||
bool _locked(String k) => _readOnly || _field(k).locked;
|
||||
bool get _enabled => _editingOrg ? (_view?.orgEnabled ?? false) : (_view?.enabled ?? false);
|
||||
|
||||
@@ -1776,6 +1904,12 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
// fields and the secret password are never prefilled.
|
||||
void _fillForm() {
|
||||
for (final f in _c.fields) {
|
||||
if (f.type == _FieldType.multiselect) {
|
||||
// A hide-list is this layer's own, not something inherited, so it comes
|
||||
// from the scope rather than from a cascaded field.
|
||||
_multi[f.key] = {...?f.scopeValue?.call(_scopeData)};
|
||||
continue;
|
||||
}
|
||||
final field = _field(f.key);
|
||||
String value;
|
||||
if (f.type == _FieldType.password) {
|
||||
@@ -1788,7 +1922,11 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
value = field.own.isNotEmpty ? field.own : f.defaultValue;
|
||||
}
|
||||
if (f.type == _FieldType.select) {
|
||||
_selects[f.key] = value;
|
||||
// A stored mode the layers above have since hidden is no longer on
|
||||
// offer, so the picker starts from the default instead of showing a
|
||||
// choice that would not take effect anyway.
|
||||
final options = _optionsFor(f);
|
||||
_selects[f.key] = options.any((o) => o.$1 == value) ? value : f.defaultValue;
|
||||
} else {
|
||||
_controllers[f.key]!.text = value;
|
||||
}
|
||||
@@ -1833,9 +1971,19 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
_saved = false;
|
||||
});
|
||||
final config = <String, dynamic>{};
|
||||
for (final f in _c.fields) {
|
||||
for (final f in _shownFields) {
|
||||
if (_locked(f.key)) continue;
|
||||
final val = f.type == _FieldType.select ? (_selects[f.key] ?? "") : _controllers[f.key]!.text;
|
||||
final String val;
|
||||
switch (f.type) {
|
||||
case _FieldType.multiselect:
|
||||
// The server stores a hide-list as the same comma-separated string the
|
||||
// API Server panel writes, so both layers round-trip identically.
|
||||
val = _optionsFor(f).map((o) => o.$1).where(_multi[f.key]!.contains).join(",");
|
||||
case _FieldType.select:
|
||||
val = _selects[f.key] ?? "";
|
||||
default:
|
||||
val = _controllers[f.key]!.text;
|
||||
}
|
||||
if (f.type == _FieldType.password && val.isEmpty) continue;
|
||||
config[f.key] = val;
|
||||
}
|
||||
@@ -1995,7 +2143,7 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
),
|
||||
|
||||
// Credential fields.
|
||||
for (final f in _c.fields) ...[
|
||||
for (final f in _shownFields) ...[
|
||||
const SizedBox(height: 12),
|
||||
_fieldWidget(f),
|
||||
],
|
||||
@@ -2083,12 +2231,51 @@ class _IntegrationCardState extends State<_IntegrationCard> {
|
||||
final field = _field(f.key);
|
||||
|
||||
Widget input;
|
||||
if (f.type == _FieldType.select) {
|
||||
final options = _optionsFor(f);
|
||||
if (f.type == _FieldType.multiselect) {
|
||||
final chosen = _multi[f.key] ??= {};
|
||||
input = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final o in options)
|
||||
InkWell(
|
||||
onTap: locked
|
||||
? null
|
||||
: () => setState(() {
|
||||
if (!chosen.remove(o.$1)) chosen.add(o.$1);
|
||||
}),
|
||||
child: Row(children: [
|
||||
Checkbox(
|
||||
value: chosen.contains(o.$1),
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onChanged: locked
|
||||
? null
|
||||
: (v) => setState(() {
|
||||
if (v ?? false) {
|
||||
chosen.add(o.$1);
|
||||
} else {
|
||||
chosen.remove(o.$1);
|
||||
}
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text(t(o.$2), style: const TextStyle(fontSize: 14))),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (f.type == _FieldType.select) {
|
||||
// A mode hidden above is gone from the list, so a stored value naming one
|
||||
// has nothing to select — fall back to the default rather than crashing
|
||||
// the dropdown on a value it does not carry.
|
||||
final current = _selects[f.key] ?? f.defaultValue;
|
||||
final value = options.any((o) => o.$1 == current) ? current : f.defaultValue;
|
||||
input = DropdownButtonFormField<String>(
|
||||
initialValue: _selects[f.key] ?? f.defaultValue,
|
||||
initialValue: value,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
|
||||
items: [
|
||||
for (final o in f.options) DropdownMenuItem(value: o.$1, child: Text(t(o.$2))),
|
||||
for (final o in options) DropdownMenuItem(value: o.$1, child: Text(t(o.$2))),
|
||||
],
|
||||
onChanged: locked ? null : (v) => setState(() => _selects[f.key] = v ?? ""),
|
||||
);
|
||||
@@ -2100,6 +2287,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 +2312,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",
|
||||
|
||||
@@ -316,6 +316,27 @@ export const api = {
|
||||
}),
|
||||
deleteHomeCharger: (id) => request(`/home-chargers/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
|
||||
// The home-charger scheduler: one list of charging tasks per user, covering
|
||||
// every charger they own. The server holds the clock — a schedule that only
|
||||
// fires while this page is open would be a reminder, not a schedule — so the
|
||||
// page only writes tasks and reads back how each one last went.
|
||||
// runChargingStep fires one step now, whatever its time and whether or not
|
||||
// the task it belongs to is switched on.
|
||||
listChargingTasks: () => request("/charging-tasks").then((r) => r.tasks),
|
||||
createChargingTask: (body) =>
|
||||
request("/charging-tasks", { method: "POST", body: JSON.stringify(body) }).then((r) => r.task),
|
||||
updateChargingTask: (id, body) =>
|
||||
request(`/charging-tasks/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}).then((r) => r.task),
|
||||
deleteChargingTask: (id) => request(`/charging-tasks/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
// 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.
|
||||
runChargingStep: (id, step) =>
|
||||
request(`/charging-tasks/${encodeURIComponent(id)}/steps/${encodeURIComponent(step)}/run`,
|
||||
{ method: "POST" }),
|
||||
|
||||
// Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix
|
||||
// returns the resolved view (effective/own/locked per field, secrets and
|
||||
// inherited emails masked); saveAnkerSolix writes the caller's editable layer;
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup>
|
||||
// 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.
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { api } from "../api";
|
||||
import { t } from "../i18n";
|
||||
import { weekdaysInOrder, weekdayShortName } from "../lib/format.js";
|
||||
import Modal from "./Modal.vue";
|
||||
import TimeField from "./TimeField.vue";
|
||||
|
||||
const props = defineProps({
|
||||
// The task being edited, or null to write a new one.
|
||||
task: { type: Object, default: null },
|
||||
// The chargers this account has, as the Charging page already loaded them.
|
||||
chargers: { type: Array, default: () => [] },
|
||||
});
|
||||
const emit = defineEmits(["saved", "close"]);
|
||||
|
||||
const editing = computed(() => !!props.task?.id);
|
||||
|
||||
const name = ref(props.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.
|
||||
const steps = ref(
|
||||
(props.task?.steps || []).length
|
||||
? props.task.steps.map((s) => ({ action: s.action, time: s.time, amps: s.amps || 16 }))
|
||||
: [{ action: "start", time: "23:00", amps: 16 }]
|
||||
);
|
||||
|
||||
// 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.
|
||||
function addStep() {
|
||||
steps.value = [...steps.value, { action: "stop", time: "06:30", amps: 16 }];
|
||||
}
|
||||
|
||||
function removeStep(i) {
|
||||
if (steps.value.length <= 1) return;
|
||||
steps.value = steps.value.filter((_, n) => n !== i);
|
||||
}
|
||||
// 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.
|
||||
const allChargers = ref(!props.task || (props.task.chargers || []).length === 0);
|
||||
const picked = ref([...(props.task?.chargers || [])]);
|
||||
const days = ref([...(props.task?.days || [])]);
|
||||
const everyDay = ref(!props.task || (props.task.days || []).length === 0);
|
||||
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
// 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 ACTIONS = ["start", "stop", "limit", "boost"];
|
||||
|
||||
// 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. A computed rather than a constant, so changing the
|
||||
// setting in another tab re-lays the row out instead of leaving it on the old
|
||||
// week. Both of these come from lib/format.js, which owns the rule for every
|
||||
// weekday row in the app.
|
||||
const weekdays = computed(() => weekdaysInOrder());
|
||||
|
||||
function toggleDay(day) {
|
||||
everyDay.value = false;
|
||||
days.value = days.value.includes(day)
|
||||
? days.value.filter((d) => d !== day)
|
||||
: [...days.value, day];
|
||||
}
|
||||
|
||||
function toggleCharger(id) {
|
||||
allChargers.value = false;
|
||||
picked.value = picked.value.includes(id)
|
||||
? picked.value.filter((x) => x !== id)
|
||||
: [...picked.value, id];
|
||||
}
|
||||
|
||||
// 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.
|
||||
watch(days, (list) => {
|
||||
if (list.length === 0) everyDay.value = true;
|
||||
});
|
||||
watch(picked, (list) => {
|
||||
if (list.length === 0) allChargers.value = true;
|
||||
});
|
||||
watch(everyDay, (on) => {
|
||||
if (on) days.value = [];
|
||||
});
|
||||
watch(allChargers, (on) => {
|
||||
if (on) picked.value = [];
|
||||
});
|
||||
|
||||
// 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.
|
||||
const canSave = computed(
|
||||
() => !!name.value.trim() && steps.value.every((s) => !!s.time) && !saving.value
|
||||
);
|
||||
|
||||
function chargerSubtitle(c) {
|
||||
return [c.serial, c.model].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSave.value) return;
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
const body = {
|
||||
name: name.value.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: steps.value.map((s) => ({
|
||||
action: s.action,
|
||||
time: s.time,
|
||||
amps: s.action === "limit" ? Number(s.amps) || 0 : 0,
|
||||
})),
|
||||
chargers: allChargers.value ? [] : [...picked.value],
|
||||
days: everyDay.value ? [] : [...days.value],
|
||||
// The time is a wall clock, and the server's is not the one it was set by.
|
||||
// Sending the zone the browser is in is what keeps 23:00 at 23:00 for a
|
||||
// server sitting in another country.
|
||||
zone: browserZone(),
|
||||
};
|
||||
try {
|
||||
const task = editing.value
|
||||
? await api.updateChargingTask(props.task.id, body)
|
||||
: await api.createChargingTask(body);
|
||||
emit("saved", task);
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function browserZone() {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:title="editing ? t('forms.chargingTask.editTitle') : t('forms.chargingTask.title')"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="submit">
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.chargingTask.name") }}</label>
|
||||
<input v-model="name" required class="dh-input" :placeholder="t('forms.chargingTask.namePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<!-- 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. -->
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.chargingTask.flow") }}</label>
|
||||
|
||||
<div v-for="(s, i) in steps" :key="i" class="mb-2 rounded-control bg-sunken p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<select v-model="s.action" class="dh-input min-w-0 flex-1">
|
||||
<option v-for="a in ACTIONS" :key="a" :value="a">{{ t(`charging.scheduler.actions.${a}`) }}</option>
|
||||
</select>
|
||||
<TimeField v-model="s.time" :aria-label="t('forms.chargingTask.time')" />
|
||||
<!-- The last step cannot go: a task with no steps has nothing to
|
||||
fire, so the control is absent rather than there and refusing. -->
|
||||
<button
|
||||
v-if="steps.length > 1"
|
||||
type="button"
|
||||
class="shrink-0 rounded-control px-2 py-2 text-xs font-medium text-muted transition-colors hover:text-danger"
|
||||
:title="t('forms.chargingTask.removeStep')"
|
||||
@click="removeStep(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The ceiling, under the one action that takes one. -->
|
||||
<div v-if="s.action === 'limit'" class="mt-3">
|
||||
<label class="dh-label">
|
||||
{{ t("forms.chargingTask.amps") }}
|
||||
<span class="data float-right font-semibold text-strong">{{ s.amps }} A</span>
|
||||
</label>
|
||||
<input v-model.number="s.amps" type="range" min="6" max="32" step="1" class="w-full accent-brand-600" />
|
||||
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.ampsHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="dh-btn dh-btn-ghost w-full !py-2 text-xs" @click="addStep">
|
||||
{{ t("forms.chargingTask.addStep") }}
|
||||
</button>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.flowHint") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Which chargers. The point of one scheduler for all of them. -->
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.chargingTask.chargers") }}</label>
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded-control p-2 hover:bg-sunken">
|
||||
<input v-model="allChargers" type="checkbox" />
|
||||
<span class="text-sm font-medium text-strong">{{ t("forms.chargingTask.allChargers") }}</span>
|
||||
</label>
|
||||
<p v-if="allChargers" class="px-2 pb-1 text-xs text-muted">{{ t("forms.chargingTask.allChargersHint") }}</p>
|
||||
|
||||
<p v-if="chargers.length === 0" class="px-2 text-xs text-muted">{{ t("forms.chargingTask.noChargers") }}</p>
|
||||
<label
|
||||
v-for="c in chargers"
|
||||
:key="c.id"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-control p-2 transition-colors hover:bg-sunken"
|
||||
>
|
||||
<input type="checkbox" :checked="!allChargers && picked.includes(c.id)" @change="toggleCharger(c.id)" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm text-strong">{{ c.name }}</span>
|
||||
<span v-if="chargerSubtitle(c)" class="data block truncate text-[11px] text-muted">{{ chargerSubtitle(c) }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Which days. -->
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.chargingTask.days") }}</label>
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded-control p-2 hover:bg-sunken">
|
||||
<input v-model="everyDay" type="checkbox" />
|
||||
<span class="text-sm font-medium text-strong">{{ t("forms.chargingTask.everyDay") }}</span>
|
||||
</label>
|
||||
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="d in weekdays"
|
||||
:key="d"
|
||||
type="button"
|
||||
class="rounded-pill border px-3 py-1.5 text-xs font-semibold transition-colors"
|
||||
:class="!everyDay && days.includes(d)
|
||||
? 'border-accent bg-brand-100 text-strong'
|
||||
: 'border-subtle text-muted hover:bg-sunken'"
|
||||
@click="toggleDay(d)"
|
||||
>
|
||||
{{ weekdayShortName(d) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
|
||||
<button type="submit" :disabled="!canSave" class="dh-btn dh-btn-primary">
|
||||
{{ saving ? t("common.saving") : t("common.save") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
// A time box that reads on the clock the user chose.
|
||||
//
|
||||
// The same problem DateField solves for dates, and the same shape of answer.
|
||||
// `<input type="time">` renders in the *browser's* locale and nothing on the
|
||||
// page moves it — `lang` included, which was measured rather than assumed: the
|
||||
// control comes out identically wide whatever it is set to, because it follows
|
||||
// navigator.languages and not the document. So a Settings → Time format of
|
||||
// 24-hour still met this account with "12:00 AM" in every schedule field,
|
||||
// disagreeing with the 00:00 the card beside it printed, and the browser's am/pm
|
||||
// did not fit the box either.
|
||||
//
|
||||
// So the typing half is ours: four digits, masked into the clock in force, with
|
||||
// the meridiem as its own control rather than something to be spelled. There is
|
||||
// no picker half — a calendar earns its button, four digits do not, and the
|
||||
// browser's time popup would have brought the same 12-hour reading back with it.
|
||||
//
|
||||
// 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.
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { clockIsTwelveHour } from "../lib/format.js";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: "" },
|
||||
disabled: { type: Boolean, default: false },
|
||||
ariaLabel: { type: String, default: "" },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const twelve = computed(() => clockIsTwelveHour());
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
|
||||
// "HH:MM" → its two numbers, or null for anything that is not a time of day.
|
||||
function parse(value) {
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(String(value || "").trim());
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
return h > 23 || min > 59 ? null : { h, min };
|
||||
}
|
||||
|
||||
// The digits the box shows: the hour as this clock writes it, and the minute.
|
||||
function toText(value) {
|
||||
const p = parse(value);
|
||||
if (!p) return "";
|
||||
return `${pad(twelve.value ? p.h % 12 || 12 : p.h)}:${pad(p.min)}`;
|
||||
}
|
||||
|
||||
// Whether the value sits in the afternoon. Only consulted on a 12-hour clock,
|
||||
// where the box cannot say it and the select has to.
|
||||
function toPm(value) {
|
||||
const p = parse(value);
|
||||
return !!p && p.h >= 12;
|
||||
}
|
||||
|
||||
// What the box and the select hold → "HH:MM", or "" while it is still half
|
||||
// typed. 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.
|
||||
function toValue(text, pm) {
|
||||
const digits = String(text).replace(/\D/g, "");
|
||||
if (digits.length !== 4) return "";
|
||||
let h = Number(digits.slice(0, 2));
|
||||
const min = Number(digits.slice(2));
|
||||
if (min > 59) return "";
|
||||
if (twelve.value) {
|
||||
if (h < 1 || h > 12) return "";
|
||||
h = (h % 12) + (pm ? 12 : 0);
|
||||
} else if (h > 23) {
|
||||
return "";
|
||||
}
|
||||
return `${pad(h)}:${pad(min)}`;
|
||||
}
|
||||
|
||||
// Digits regrouped as hh:mm. No trailing colon: it appears with the next digit,
|
||||
// and adding it early only gives backspace something to fight with.
|
||||
function mask(text) {
|
||||
const digits = String(text).replace(/\D/g, "").slice(0, 4);
|
||||
return digits.length > 2 ? `${digits.slice(0, 2)}:${digits.slice(2)}` : digits;
|
||||
}
|
||||
|
||||
const text = ref(toText(props.modelValue));
|
||||
const pm = ref(toPm(props.modelValue));
|
||||
|
||||
// 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.
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (toValue(text.value, pm.value) === (value || "")) return;
|
||||
text.value = toText(value);
|
||||
pm.value = toPm(value);
|
||||
}
|
||||
);
|
||||
|
||||
// Switching the setting in another tab re-lays out what is already in the box,
|
||||
// rather than leaving one field on the old clock.
|
||||
watch(twelve, () => {
|
||||
text.value = toText(props.modelValue);
|
||||
pm.value = toPm(props.modelValue);
|
||||
});
|
||||
|
||||
function onInput(event) {
|
||||
const el = event.target;
|
||||
const masked = mask(el.value);
|
||||
text.value = masked;
|
||||
// Written straight to the DOM: Vue skips the patch when the bound value is
|
||||
// unchanged from the last render, which would leave a stray character the
|
||||
// user just typed sitting in the box.
|
||||
el.value = masked;
|
||||
emit("update:modelValue", toValue(masked, pm.value));
|
||||
}
|
||||
|
||||
function onMeridiem(event) {
|
||||
pm.value = event.target.value === "pm";
|
||||
emit("update:modelValue", toValue(text.value, pm.value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
class="dh-input data w-20 shrink-0 text-center"
|
||||
:value="text"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
placeholder="--:--"
|
||||
maxlength="5"
|
||||
@input="onInput"
|
||||
/>
|
||||
<select
|
||||
v-if="twelve"
|
||||
class="dh-input w-16 shrink-0"
|
||||
:value="pm ? 'pm' : 'am'"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
@change="onMeridiem"
|
||||
>
|
||||
<option value="am">am</option>
|
||||
<option value="pm">pm</option>
|
||||
</select>
|
||||
</span>
|
||||
</template>
|
||||
@@ -50,7 +50,8 @@
|
||||
"tabs": {
|
||||
"dragHint": "Træk en fane for at ændre rækkefølgen af opladningsfanerne.",
|
||||
"public": "Offentlige ladere",
|
||||
"home": "Hjemmeladere"
|
||||
"home": "Hjemmeladere",
|
||||
"scheduler": "Planlægning af hjemmelader"
|
||||
},
|
||||
"liveMap": "Live-kort",
|
||||
"youAreHere": "Du er her",
|
||||
@@ -72,6 +73,8 @@
|
||||
"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",
|
||||
@@ -383,6 +386,33 @@
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -520,6 +550,16 @@
|
||||
"regionHint": "Tal- og valutaformat.",
|
||||
"dateFormat": "Datoformat",
|
||||
"dateExample": "Eksempel: {example}",
|
||||
"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}",
|
||||
"currency": "Valuta",
|
||||
"currencyExample": "Eksempel: {example} — kun visning, ingen beløb omregnes.",
|
||||
"fontSize": "Skriftstørrelse",
|
||||
@@ -586,6 +626,8 @@
|
||||
"countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).",
|
||||
"controlMode": "Styringstilstand",
|
||||
"controlModeHint": "Hvordan DriverVault styrer laderen.",
|
||||
"controlModesHidden": "Skjul for dine brugere",
|
||||
"controlModesHiddenHint": "Tilstande du markerer her forsvinder fra dine brugeres liste og træder ikke længere i kraft for dem. Kun overvågning er altid tilgængelig.",
|
||||
"controlOff": "Fra (kun overvågning)",
|
||||
"controlOwn": "Eget CSMS (fuld styring)",
|
||||
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
|
||||
@@ -1232,6 +1274,25 @@
|
||||
"submit": "Del",
|
||||
"peopleWithAccess": "Personer med adgang",
|
||||
"notShared": "Endnu ikke delt med nogen."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
"tabs": {
|
||||
"dragHint": "Drag a tab to rearrange the charging tabs.",
|
||||
"public": "Public chargers",
|
||||
"home": "Home chargers"
|
||||
"home": "Home chargers",
|
||||
"scheduler": "Home charger scheduler"
|
||||
},
|
||||
"liveMap": "Live map",
|
||||
"youAreHere": "You are here",
|
||||
@@ -353,6 +354,8 @@
|
||||
"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",
|
||||
@@ -382,6 +385,33 @@
|
||||
"full": "Full",
|
||||
"offPeak": "off-peak",
|
||||
"homeCharger": "Home charger"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -519,6 +549,16 @@
|
||||
"regionHint": "Number and currency layout.",
|
||||
"dateFormat": "Date format",
|
||||
"dateExample": "Example: {example}",
|
||||
"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}",
|
||||
"currency": "Currency",
|
||||
"currencyExample": "Example: {example} — display only, no amounts are converted.",
|
||||
"fontSize": "Font size",
|
||||
@@ -585,6 +625,8 @@
|
||||
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
|
||||
"controlMode": "Control mode",
|
||||
"controlModeHint": "How DriverVault controls the charger.",
|
||||
"controlModesHidden": "Hide from your users",
|
||||
"controlModesHiddenHint": "Modes you tick here disappear from your users' picker and stop taking effect for them. Monitoring only is always available.",
|
||||
"controlOff": "Off (monitoring only)",
|
||||
"controlOwn": "Own CSMS (full control)",
|
||||
"controlProxy": "Proxy CSMS (relay + control)",
|
||||
@@ -1231,6 +1273,25 @@
|
||||
"submit": "Share",
|
||||
"peopleWithAccess": "People with access",
|
||||
"notShared": "Not shared with anyone yet."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
"tabs": {
|
||||
"dragHint": "Przeciągnij kartę, aby zmienić kolejność kart ładowania.",
|
||||
"public": "Ładowarki publiczne",
|
||||
"home": "Ładowarki domowe"
|
||||
"home": "Ładowarki domowe",
|
||||
"scheduler": "Harmonogram ładowarki"
|
||||
},
|
||||
"liveMap": "Mapa na żywo",
|
||||
"youAreHere": "Tu jesteś",
|
||||
@@ -72,6 +73,8 @@
|
||||
"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ń",
|
||||
@@ -385,6 +388,35 @@
|
||||
"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."
|
||||
},
|
||||
"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ę"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -524,6 +556,16 @@
|
||||
"regionHint": "Format liczb i waluty.",
|
||||
"dateFormat": "Format daty",
|
||||
"dateExample": "Przykład: {example}",
|
||||
"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}",
|
||||
"currency": "Waluta",
|
||||
"currencyExample": "Przykład: {example} — tylko wyświetlanie, kwoty nie są przeliczane.",
|
||||
"fontSize": "Rozmiar czcionki",
|
||||
@@ -590,6 +632,8 @@
|
||||
"countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).",
|
||||
"controlMode": "Tryb sterowania",
|
||||
"controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką.",
|
||||
"controlModesHidden": "Ukryj przed użytkownikami",
|
||||
"controlModesHiddenHint": "Zaznaczone tryby znikają z listy Twoich użytkowników i przestają dla nich działać. Tylko monitorowanie jest zawsze dostępne.",
|
||||
"controlOff": "Wyłączone (tylko monitorowanie)",
|
||||
"controlOwn": "Własny CSMS (pełne sterowanie)",
|
||||
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
|
||||
@@ -1246,6 +1290,25 @@
|
||||
"submit": "Udostępnij",
|
||||
"peopleWithAccess": "Osoby z dostępem",
|
||||
"notShared": "Jeszcze nikomu nie udostępniono."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -76,8 +76,151 @@ export function formatDateTime(value) {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (isNaN(d)) return "—";
|
||||
const time = d.toLocaleTimeString(prefs.locale || undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
return `${formatDate(value)} ${time}`;
|
||||
return `${formatDate(value)} ${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.
|
||||
//
|
||||
// hourCycle rather than hour12: with hour12:false the en-US formatter prints
|
||||
// midnight as 24:00.
|
||||
// The setting decides *which* clock; this file decides how it is punctuated.
|
||||
//
|
||||
// That split is the whole of it. 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 same two lines below, and the region is
|
||||
// asked one question only, under "auto".
|
||||
//
|
||||
// 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.
|
||||
export function formatTime(value) {
|
||||
if (!value) return "—";
|
||||
const d = value instanceof Date ? value : new Date(value);
|
||||
if (isNaN(d)) return "—";
|
||||
const h = d.getHours();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const mm = pad(d.getMinutes());
|
||||
|
||||
if (!clockIsTwelveHour()) return `${pad(h)}:${mm}`;
|
||||
// 12 for both noon and midnight, and midnight is the am one.
|
||||
return `${pad(h % 12 || 12)}:${mm} ${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.
|
||||
//
|
||||
// Exported because printing a time is not the only thing that has to know. A
|
||||
// control that lets somebody *enter* one 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 components/TimeField.vue).
|
||||
export function clockIsTwelveHour() {
|
||||
const mode = prefs.timeFormat;
|
||||
if (mode === "12") return true;
|
||||
if (mode === "24") return false;
|
||||
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 when the region does.
|
||||
const twelveHourRegions = new Map();
|
||||
|
||||
function regionReadsTwelveHour() {
|
||||
const locale = prefs.locale || "";
|
||||
if (!twelveHourRegions.has(locale)) {
|
||||
let twelve = false;
|
||||
try {
|
||||
const cycle = new Intl.DateTimeFormat(locale || undefined, { hour: "numeric" })
|
||||
.resolvedOptions().hourCycle;
|
||||
twelve = cycle === "h11" || cycle === "h12";
|
||||
} 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.
|
||||
}
|
||||
twelveHourRegions.set(locale, twelve);
|
||||
}
|
||||
return twelveHourRegions.get(locale);
|
||||
}
|
||||
|
||||
// --- 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 misclick — 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 two, so there
|
||||
// is one answer to "which day comes first" rather than one per screen. Days are
|
||||
// numbered the way Date.getDay() and 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.
|
||||
export function weekStartsOnMonday() {
|
||||
const mode = prefs.weekStart;
|
||||
if (mode === "monday") return true;
|
||||
if (mode === "sunday") return false;
|
||||
return regionStartsOnMonday();
|
||||
}
|
||||
|
||||
// The one question "auto" asks the region. Cached per locale like the clock's,
|
||||
// and for the same reason — it is asked once per weekday button.
|
||||
const mondayRegions = new Map();
|
||||
|
||||
function regionStartsOnMonday() {
|
||||
const locale = prefs.locale || "";
|
||||
if (!mondayRegions.has(locale)) {
|
||||
// ISO 8601 numbers the days 1 = Monday … 7 = Sunday, which is what weekInfo
|
||||
// reports. Browsers expose it as a method on some engines and a property on
|
||||
// others, hence both.
|
||||
let monday = true;
|
||||
try {
|
||||
const info = new Intl.Locale(locale || "en-US");
|
||||
const first = (info.getWeekInfo?.() || info.weekInfo)?.firstDay;
|
||||
if (first) monday = first === 1;
|
||||
} catch {
|
||||
// An engine without week information, or an unusable locale. 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.
|
||||
}
|
||||
mondayRegions.set(locale, monday);
|
||||
}
|
||||
return mondayRegions.get(locale);
|
||||
}
|
||||
|
||||
// The seven days in the order they should be drawn, as day numbers.
|
||||
export function weekdaysInOrder() {
|
||||
return weekStartsOnMonday() ? [1, 2, 3, 4, 5, 6, 0] : [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.
|
||||
export function weekdayShortName(day) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(prefs.locale || undefined, { weekday: "short", timeZone: "UTC" })
|
||||
.format(new Date(Date.UTC(2024, 0, 7 + day)));
|
||||
} catch {
|
||||
return String(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.
|
||||
export function sortWeekdays(days) {
|
||||
const order = weekdaysInOrder();
|
||||
return [...(days || [])].sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
||||
}
|
||||
|
||||
// Every number we render goes through here so the grouping separator follows
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
|
||||
import { prefs } from "../prefs.js";
|
||||
|
||||
export const CHARGING_TABS = ["public", "home"];
|
||||
// The scheduler comes last by default: it acts on the chargers the tab before
|
||||
// it lists, so it reads as the thing you set up once the chargers are there.
|
||||
export const CHARGING_TABS = ["public", "home", "scheduler"];
|
||||
|
||||
// A car's tabs in their default order. Information sits second because the
|
||||
// connected service, when there is one, is what you came to look at.
|
||||
|
||||
@@ -6,6 +6,11 @@ export const prefs = reactive({
|
||||
theme: "system", // light | dark | system
|
||||
locale: "en-US",
|
||||
dateFormat: "YMD", // YMD | DMY | MDY
|
||||
timeFormat: "auto", // auto (the region's own convention) | 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 lib/format.js, which
|
||||
// owns the rule so every such row reads the same.
|
||||
weekStart: "auto", // auto (the region's own convention) | monday | sunday
|
||||
currency: "USD", // ISO 4217 code
|
||||
fontSize: "medium", // small | medium | large
|
||||
// Holds every arrangement still: the garage, a car's tabs, its Information
|
||||
@@ -56,6 +61,8 @@ export function applyProfilePrefs(profile) {
|
||||
prefs.theme = profile.theme || "system";
|
||||
prefs.locale = profile.locale || "en-US";
|
||||
prefs.dateFormat = profile.dateFormat || "YMD";
|
||||
prefs.timeFormat = profile.timeFormat || "auto";
|
||||
prefs.weekStart = profile.weekStart || "auto";
|
||||
prefs.currency = profile.currency || "USD";
|
||||
prefs.fontSize = profile.fontSize || "medium";
|
||||
prefs.dragLocked = !!profile.dragLocked;
|
||||
|
||||
@@ -361,6 +361,21 @@ body {
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
|
||||
/* Checkbox for the small multi-choice lists (hidden control modes, ...).
|
||||
accent-color keeps the native control and its keyboard behaviour. */
|
||||
.dh-checkbox {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: none;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dh-checkbox:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dh-label {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { prefs } from "../prefs";
|
||||
import { askConfirm } from "../lib/confirm.js";
|
||||
import { api } from "../api";
|
||||
import { formatDateTime } from "../lib/format.js";
|
||||
import { formatDateTime, clockIsTwelveHour, weekdayShortName, sortWeekdays }
|
||||
from "../lib/format.js";
|
||||
import TimeField from "../components/TimeField.vue";
|
||||
import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js";
|
||||
import ChargerImportModal from "../components/ChargerImportModal.vue";
|
||||
import ChargingTaskModal from "../components/ChargingTaskModal.vue";
|
||||
|
||||
// Charging & map screen, mirroring the web-dashboard UI kit. There is no live
|
||||
// charging API yet (only the Anker Solix credential cascade in Settings), so the
|
||||
@@ -337,6 +340,23 @@ const ctlStatusLabel = computed(() => {
|
||||
return (ctlReadsDevice.value ? s.statusDesc : s.connectorStatus) || "—";
|
||||
});
|
||||
|
||||
// And named for whichever of the two it is showing. The tile was called
|
||||
// "Connector" from when OCPP was the only thing it read: an OCPP connector state
|
||||
// is what that word means. Reading the charger's own snapshot it holds the
|
||||
// charger's own status — the same statusDesc the readings card shows as
|
||||
// "Charging status", so it takes that card's name for it rather than a second
|
||||
// name for one value.
|
||||
const ctlStatusTitle = computed(() =>
|
||||
ctlReadsDevice.value ? t("charging.modbus.chargingStatus") : t("charging.control.status")
|
||||
);
|
||||
|
||||
// The energy tile has the same two readings and the same problem: "Energy" is
|
||||
// what an OCPP meter total is, and the charger's own snapshot counts this
|
||||
// session's energy instead. Named from the same card as the status above it.
|
||||
const ctlMeterTitle = computed(() =>
|
||||
ctlReadsDevice.value ? t("charging.modbus.sessionEnergy") : t("charging.control.meter")
|
||||
);
|
||||
|
||||
// --- The charger's own snapshot, grouped for reading ---
|
||||
//
|
||||
// Reading the charger directly reports far more than OCPP does: one poll carries
|
||||
@@ -602,7 +622,10 @@ const MQTT_SETTING_BLOCKS = [
|
||||
id: "charging",
|
||||
title: "blockCharging",
|
||||
fields: [
|
||||
{ key: "maxCurrentA", at: "settings.maxCurrentA", label: "maxCurrentSet", type: "number", min: LIMIT_FLOOR, max: null, step: 1, unit: "A" },
|
||||
// A slider rather than a box, like the control card had and the Modbus
|
||||
// settings card still has: it is the same value they set, and a ceiling
|
||||
// is a thing you slide between two known ends rather than type.
|
||||
{ key: "maxCurrentA", at: "settings.maxCurrentA", label: "maxCurrentSet", type: "slider", min: LIMIT_FLOOR, max: null, step: 1, unit: "A", hint: "limitFloorHint" },
|
||||
{ key: "autoStart", at: "settings.autoStart", label: "autoStart", type: "switch" },
|
||||
{ key: "randomDelay", at: "settings.randomDelay", label: "randomDelay", type: "switch" },
|
||||
{ key: "plugLock", at: "settings.plugLock", label: "plugLock", type: "switch" },
|
||||
@@ -634,7 +657,10 @@ const MQTT_SETTING_BLOCKS = [
|
||||
fields: [
|
||||
{ key: "solarBalancing", at: "solarBalancing", label: "solarBalancing", type: "switch" },
|
||||
{ key: "solarChargeMode", at: "settings.solarChargeMode", label: "solarChargeMode", type: "option", enum: "solarMode", values: [0, 1] },
|
||||
{ key: "solarMinCurrentA", at: "settings.solarMinCurrentA", label: "solarMinCurrent", type: "number", min: LIMIT_FLOOR, max: 32, step: 1, unit: "A" },
|
||||
// A slider, like the current limit it shares a floor with. No floor note
|
||||
// under it though: this is the least a solar charge will draw, not a
|
||||
// ceiling, so the limit slider's hint would be saying the wrong thing.
|
||||
{ key: "solarMinCurrentA", at: "settings.solarMinCurrentA", label: "solarMinCurrent", type: "slider", min: LIMIT_FLOOR, max: 32, step: 1, unit: "A" },
|
||||
// This command offers automatic and single-phase only. The three-phase
|
||||
// setting is a Modbus register, and offering it here would be offering a
|
||||
// write that comes back refused.
|
||||
@@ -646,7 +672,9 @@ const MQTT_SETTING_BLOCKS = [
|
||||
id: "panel",
|
||||
title: "blockPanel",
|
||||
fields: [
|
||||
{ key: "ledBrightness", at: "ledBrightness", label: "ledBrightness", type: "number", min: 0, max: 100, step: 10, unit: "%" },
|
||||
// A slider, like the current limit: a brightness is a place on a range,
|
||||
// and typing 70 into a box that only takes tens is a worse way to say it.
|
||||
{ key: "ledBrightness", at: "ledBrightness", label: "ledBrightness", type: "slider", min: 0, max: 100, step: 10, unit: "%" },
|
||||
{ key: "lightOffSchedule", at: "settings.lightOffSchedule", label: "lightOff", type: "switch" },
|
||||
{ type: "window", label: "lightOffWindow", from: "lightOffStart", to: "lightOffEnd", at: ["settings.lightOffStart", "settings.lightOffEnd"] },
|
||||
{ key: "swipeUpMode", at: "swipeUpMode", label: "swipeUp", type: "option", enum: "gesture", values: [0, 1, 2, 3] },
|
||||
@@ -686,18 +714,35 @@ const mqttDraft = ref({});
|
||||
const mqttBase = ref({});
|
||||
const mqttBusy = ref("");
|
||||
|
||||
// The settings the charger has reported, kept per serial across reads.
|
||||
//
|
||||
// The two halves of a snapshot arrive on different messages: the telemetry comes
|
||||
// from the trigger, the settings only when the charger has something to say
|
||||
// about itself. A read can land with the first and not the second — most often
|
||||
// the first read after a reconnect, before any settings frame has arrived — and
|
||||
// seeding the controls from that answer alone emptied the card of everything the
|
||||
// charger had already told us. Which is not the state of the charger; it is the
|
||||
// state of one message.
|
||||
//
|
||||
// So a value the charger has reported stays until it reports another. The values
|
||||
// are its own last word either way, and the same ones the server fills a grouped
|
||||
// command's siblings from when a caller leaves them out.
|
||||
const mqttSeen = ref({}); // serial → the last value each setting was reported with
|
||||
|
||||
function syncMqttSettings() {
|
||||
const draft = {};
|
||||
const sn = ctlSerial.value.trim();
|
||||
const seen = { ...(mqttSeen.value[sn] || {}) };
|
||||
for (const block of MQTT_SETTING_BLOCKS) {
|
||||
for (const f of block.fields) {
|
||||
for (const [key, at] of fieldEntries(f)) {
|
||||
const v = snapshotValue(at);
|
||||
if (isSet(v) && v !== "") draft[key] = v;
|
||||
if (isSet(v) && v !== "") seen[key] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
mqttDraft.value = draft;
|
||||
mqttBase.value = { ...draft };
|
||||
mqttSeen.value = { ...mqttSeen.value, [sn]: seen };
|
||||
mqttDraft.value = { ...seen };
|
||||
mqttBase.value = { ...seen };
|
||||
}
|
||||
|
||||
// Only the settings the charger has actually reported get a control. A value it
|
||||
@@ -967,29 +1012,74 @@ const chargerLive = ref({}); // provider charger id → the service's own record
|
||||
const chargerLiveLoading = ref(false);
|
||||
const chargerLiveLoaded = ref(false);
|
||||
|
||||
async function loadChargerLive(force = false) {
|
||||
async function loadChargerLive(force = false, withDetails = true) {
|
||||
const connected = chargerProviders.value.filter((p) => p.connected);
|
||||
if (chargerLiveLoading.value || connected.length === 0) return;
|
||||
if (chargerLiveLoaded.value && !force) return; // switching tabs is not a new question
|
||||
chargerLiveLoading.value = true;
|
||||
const live = {};
|
||||
let answered = false;
|
||||
await Promise.all(
|
||||
connected.map(async (p) => {
|
||||
try {
|
||||
const res = await api.listProviderChargers(p.id);
|
||||
for (const c of res?.chargers || []) live[c.id] = c;
|
||||
answered = true;
|
||||
} catch {
|
||||
// A service that will not answer leaves its chargers unknown rather
|
||||
// than offline — this page cannot tell those two apart.
|
||||
}
|
||||
})
|
||||
);
|
||||
chargerLive.value = live;
|
||||
chargerLiveLoaded.value = true;
|
||||
// Merged rather than replaced, and only when something actually answered. A
|
||||
// service that could not be reached used to take every charger it knows about
|
||||
// grey with it and then mark the question asked, so the list sat colourless
|
||||
// until somebody pressed Refresh. "We could not ask" is not an answer, and it
|
||||
// is certainly not "unknown" — the last thing the service did say still
|
||||
// stands, and a provider that did answer overwrites its own entries here.
|
||||
if (answered) {
|
||||
chargerLive.value = { ...chargerLive.value, ...live };
|
||||
chargerLiveLoaded.value = true;
|
||||
}
|
||||
chargerLiveLoading.value = false;
|
||||
// The card's other half: the views that answer per charger rather than per
|
||||
// account. Refreshing the card refreshes both.
|
||||
loadChargerDetails(force);
|
||||
// account. Skipped by the poll below — those are a dozen cloud endpoints per
|
||||
// charger, and reachability is one call per service.
|
||||
if (withDetails) loadChargerDetails(force);
|
||||
}
|
||||
|
||||
// --- Keeping the list's reachability current ---------------------------------
|
||||
//
|
||||
// Who is reachable changes on its own, so asking once when the tab opens left
|
||||
// the bolts saying whatever was true when it did. This re-asks while the tab is
|
||||
// actually being looked at: not while the page is hidden, not on the public tab,
|
||||
// and not the per-charger views — one call per connected service, which came
|
||||
// back in about a quarter of a second.
|
||||
const LIVE_POLL_MS = 30000;
|
||||
let livePoll = null;
|
||||
|
||||
function stopLivePoll() {
|
||||
if (livePoll) {
|
||||
clearInterval(livePoll);
|
||||
livePoll = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startLivePoll() {
|
||||
stopLivePoll();
|
||||
if (chargerTab.value !== "home" || document.hidden) return;
|
||||
livePoll = setInterval(() => loadChargerLive(true, false), LIVE_POLL_MS);
|
||||
}
|
||||
|
||||
// Coming back to a tab that has been hidden asks straight away rather than
|
||||
// waiting out the rest of an interval that was never going to fire.
|
||||
function onPageVisibility() {
|
||||
if (document.hidden) {
|
||||
stopLivePoll();
|
||||
return;
|
||||
}
|
||||
loadChargerLive(true, false);
|
||||
startLivePoll();
|
||||
}
|
||||
|
||||
// The live half for one imported charger, or null when the service it came from
|
||||
@@ -1000,6 +1090,31 @@ function liveFor(c) {
|
||||
return chargerLive.value[c.providerChargerId] || chargerLive.value[c.serial] || null;
|
||||
}
|
||||
|
||||
// --- The charger the buttons act on, as a picture and a name ------------------
|
||||
//
|
||||
// The control card drives whichever serial is in force, which is not always the
|
||||
// record highlighted in the list beside it, so it identifies its charger by that
|
||||
// serial rather than by the selection. Two sources carry the same product shot:
|
||||
// the account's own charger list, and the live half held per provider. Either
|
||||
// will do; the account's is the one that arrives without the home tab having
|
||||
// been opened.
|
||||
//
|
||||
// It lives here rather than up with the other ctl* values because both of its
|
||||
// sources do, and a lookup reads better next to the map it reads from.
|
||||
function accountCharger(sn) {
|
||||
if (!sn) return null;
|
||||
return chargers.value.find((c) => c.sn === sn) || chargerLive.value[sn] || null;
|
||||
}
|
||||
|
||||
const ctlImageUrl = computed(() => accountCharger(ctlSerial.value.trim())?.imageUrl || "");
|
||||
const ctlChargerName = computed(() => accountCharger(ctlSerial.value.trim())?.name || "");
|
||||
|
||||
// A product shot is a URL from the service, and a URL can 404. Remembered per
|
||||
// URL rather than per charger, so the picture simply stops being drawn and comes
|
||||
// back on its own if the service starts answering for it again — no reset to
|
||||
// forget when the serial changes.
|
||||
const imageFailed = ref({});
|
||||
|
||||
// How the charger is registered on the account, in the service's own terms.
|
||||
function sourcesLabel(sources) {
|
||||
if (!sources?.length) return "";
|
||||
@@ -1473,14 +1588,7 @@ async function addRfidCard() {
|
||||
rfidBusy.value = "new";
|
||||
rfidError.value = "";
|
||||
try {
|
||||
const res = await api.saveAnkerRfidCard(sn, number, newCardName.value.trim());
|
||||
rfidWritten.value = { ...rfidWritten.value, [sn]: res?.cards || [] };
|
||||
if (res?.present === false) {
|
||||
rfidError.value = t("charging.rfid.notAdded");
|
||||
} else {
|
||||
newCardNumber.value = "";
|
||||
newCardName.value = "";
|
||||
}
|
||||
await writeCard(sn, number);
|
||||
} catch (e) {
|
||||
rfidError.value = e.message;
|
||||
} finally {
|
||||
@@ -1488,6 +1596,44 @@ async function addRfidCard() {
|
||||
}
|
||||
}
|
||||
|
||||
// Writing one card and then reading the list back, which is the only thing that
|
||||
// says whether the write landed. Shared by both ways of adding one, so the tap
|
||||
// and the typed number cannot end up judging their answers differently.
|
||||
//
|
||||
// The name is whatever is in the box, and an empty box is not a missing name: it
|
||||
// is the server's own convention — "RFID" and the card's last four digits — and
|
||||
// leaving it to the server is what keeps the two ways of adding a card from
|
||||
// drifting into two naming conventions.
|
||||
async function writeCard(sn, number) {
|
||||
const res = await api.saveAnkerRfidCard(sn, number, newCardName.value.trim());
|
||||
rfidWritten.value = { ...rfidWritten.value, [sn]: res?.cards || [] };
|
||||
if (res?.present === false) {
|
||||
rfidError.value = t("charging.rfid.notAdded");
|
||||
return false;
|
||||
}
|
||||
newCardNumber.value = "";
|
||||
newCardName.value = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
// The reader's own twenty seconds, as a value rather than as a side effect on
|
||||
// the form: one caller wants the number in the box, the other wants to write it.
|
||||
// The caller owns rfidBusy, so the tap-and-save button can hold it across the
|
||||
// write that follows and nothing re-enables between the two.
|
||||
async function readCardAtCharger(sn) {
|
||||
rfidCountdown.value = 20;
|
||||
const tick = setInterval(() => {
|
||||
rfidCountdown.value = Math.max(0, rfidCountdown.value - 1);
|
||||
}, 1000);
|
||||
try {
|
||||
const res = await api.scanAnkerRfidCard(sn);
|
||||
return res?.tapped && res.card ? res.card : "";
|
||||
} finally {
|
||||
clearInterval(tick);
|
||||
rfidCountdown.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Asking the charger to read a card, which is what the Anker app's second way of
|
||||
// adding one does: the reader opens for twenty seconds, and whatever is held
|
||||
// against it comes back as a number. Nothing is written by this — the card lands
|
||||
@@ -1497,22 +1643,42 @@ async function scanRfidCard() {
|
||||
if (!sn || rfidBusy.value) return;
|
||||
rfidBusy.value = "scan";
|
||||
rfidError.value = "";
|
||||
rfidCountdown.value = 20;
|
||||
const tick = setInterval(() => {
|
||||
rfidCountdown.value = Math.max(0, rfidCountdown.value - 1);
|
||||
}, 1000);
|
||||
try {
|
||||
const res = await api.scanAnkerRfidCard(sn);
|
||||
if (res?.tapped && res.card) {
|
||||
newCardNumber.value = res.card;
|
||||
} else {
|
||||
const card = await readCardAtCharger(sn);
|
||||
if (card) newCardNumber.value = card;
|
||||
else rfidError.value = t("charging.rfid.tapNone");
|
||||
} catch (e) {
|
||||
rfidError.value = e.message;
|
||||
} finally {
|
||||
rfidBusy.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
// The same tap, carried through to the end: the reader opens, and whatever is
|
||||
// held against it is written without a second press. Enrolling a card happens at
|
||||
// the charger with the card in your hand — the walk back to the keyboard to
|
||||
// press Add was the whole cost of the two-step version.
|
||||
//
|
||||
// Nothing new is written by this that the two buttons above could not write
|
||||
// between them; it is the same read and the same write, with nothing to do in
|
||||
// between. The number lands in the box on the way past, so a write that fails
|
||||
// leaves something to look at and retry rather than a card nobody can name.
|
||||
async function tapAndSaveRfidCard() {
|
||||
const sn = detailSn.value;
|
||||
if (!sn || rfidBusy.value) return;
|
||||
rfidBusy.value = "tapSave";
|
||||
rfidError.value = "";
|
||||
try {
|
||||
const card = await readCardAtCharger(sn);
|
||||
if (!card) {
|
||||
rfidError.value = t("charging.rfid.tapNone");
|
||||
return;
|
||||
}
|
||||
newCardNumber.value = card;
|
||||
await writeCard(sn, card);
|
||||
} catch (e) {
|
||||
rfidError.value = e.message;
|
||||
} finally {
|
||||
clearInterval(tick);
|
||||
rfidCountdown.value = 0;
|
||||
rfidBusy.value = "";
|
||||
}
|
||||
}
|
||||
@@ -1617,12 +1783,58 @@ async function refreshCtl() {
|
||||
modbusHost.value = ctl.value?.modbusHost || "";
|
||||
modbusPort.value = ctl.value?.modbusPort || 502;
|
||||
syncSettingsDraft();
|
||||
chaseSettings();
|
||||
} catch (e) {
|
||||
ctlError.value = e.message;
|
||||
ctl.value = null;
|
||||
stopSettingsRetry();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Waiting for the half of the snapshot that answers late -------------------
|
||||
//
|
||||
// A snapshot has two halves on two messages. The telemetry comes from the
|
||||
// trigger and is there by the time the read returns; the settings come when the
|
||||
// charger gets round to answering the request for them, which on this A5191 was
|
||||
// measured at around half a minute — long after the read that asked has gone.
|
||||
// The frame is not lost: it lands in the server's state and the next read
|
||||
// carries it. But nothing on this page took a next read, so a settings card that
|
||||
// came up empty stayed empty until something else happened to refresh it, which
|
||||
// is what "it doesn't always load" was.
|
||||
//
|
||||
// So a refresh that comes back without them queues another look, at widening
|
||||
// gaps, and then stops. Stopping matters: the message the server asks with is
|
||||
// one the reference reads as carrying an Anker bug, so a charger that never
|
||||
// answers it is a real possibility, and a page left open all day must not poll
|
||||
// one for ever.
|
||||
const SETTINGS_RETRY_MS = [6000, 12000, 24000, 45000];
|
||||
let settingsRetryAt = 0;
|
||||
let settingsRetryFor = "";
|
||||
let settingsRetryTimer = null;
|
||||
|
||||
function stopSettingsRetry() {
|
||||
clearTimeout(settingsRetryTimer);
|
||||
settingsRetryTimer = null;
|
||||
}
|
||||
|
||||
function chaseSettings() {
|
||||
const sn = ctlSerial.value.trim();
|
||||
// A different charger is a different question, and gets its own patience.
|
||||
if (sn !== settingsRetryFor) {
|
||||
settingsRetryFor = sn;
|
||||
settingsRetryAt = 0;
|
||||
}
|
||||
stopSettingsRetry();
|
||||
if (!sn || !ctlReadsDevice.value || dev.value.settings) {
|
||||
settingsRetryAt = 0;
|
||||
return;
|
||||
}
|
||||
const wait = SETTINGS_RETRY_MS[settingsRetryAt];
|
||||
if (wait === undefined) return; // asked enough times; the Refresh button remains
|
||||
settingsRetryAt++;
|
||||
settingsRetryTimer = setTimeout(refreshCtl, wait);
|
||||
}
|
||||
|
||||
// --- The charger's address on the local network (Modbus mode) ---
|
||||
// Enabled on the device in the Anker app under Settings > Integrations > Modbus
|
||||
// TCP, which is where the address to type here comes from.
|
||||
@@ -1715,20 +1927,203 @@ function cancelReset() {
|
||||
resetPassword.value = "";
|
||||
}
|
||||
|
||||
// --- The scheduler: one list of charging tasks, covering every charger --------
|
||||
//
|
||||
// The charger's own cloud schedule is one window inside one box: charge between
|
||||
// these hours, every day, and that is the whole vocabulary. This is a list —
|
||||
// each line an action, a time, the days it repeats on and the chargers it acts
|
||||
// on — and one list covers the whole account rather than each charger hiding its
|
||||
// own.
|
||||
//
|
||||
// The clock is the server's, not this page's. A schedule that only fires while a
|
||||
// tab is open would be a reminder; the page writes tasks and reads back how each
|
||||
// one last went.
|
||||
const tasks = ref([]);
|
||||
const tasksError = ref("");
|
||||
const tasksLoaded = ref(false);
|
||||
const showTaskForm = ref(false);
|
||||
const editingTask = ref(null); // the task being edited, or null for a new one
|
||||
const runningTask = ref(""); // "<task id>:<step index>" of the step being fired
|
||||
const togglingTask = ref("");
|
||||
|
||||
async function loadChargingTasks() {
|
||||
tasksError.value = "";
|
||||
try {
|
||||
tasks.value = await api.listChargingTasks();
|
||||
tasksLoaded.value = true;
|
||||
} catch (e) {
|
||||
tasksError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function newTask() {
|
||||
editingTask.value = null;
|
||||
showTaskForm.value = true;
|
||||
}
|
||||
|
||||
function editTask(task) {
|
||||
editingTask.value = task;
|
||||
showTaskForm.value = true;
|
||||
}
|
||||
|
||||
// A saved task replaces its old self in place rather than the list being fetched
|
||||
// again: the row that was just edited should not move under the pointer that
|
||||
// edited it, and a new one belongs where its time puts it.
|
||||
function onTaskSaved(task) {
|
||||
showTaskForm.value = false;
|
||||
editingTask.value = null;
|
||||
const i = tasks.value.findIndex((x) => x.id === task.id);
|
||||
if (i >= 0) tasks.value.splice(i, 1, task);
|
||||
else {
|
||||
// Ordered by the time each task begins, which is what the server sends back
|
||||
// and what the day runs them in.
|
||||
tasks.value = [...tasks.value, task].sort(
|
||||
(a, b) => (a.steps?.[0]?.time || "").localeCompare(b.steps?.[0]?.time || "")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The switch in the row. Written straight through rather than optimistically:
|
||||
// a schedule that says it is on when the server thinks otherwise is the one
|
||||
// mistake this list must not make.
|
||||
async function toggleTask(task) {
|
||||
tasksError.value = "";
|
||||
togglingTask.value = task.id;
|
||||
try {
|
||||
const saved = await api.updateChargingTask(task.id, { enabled: !task.enabled });
|
||||
const i = tasks.value.findIndex((x) => x.id === task.id);
|
||||
if (i >= 0) tasks.value.splice(i, 1, saved);
|
||||
} catch (e) {
|
||||
tasksError.value = e.message;
|
||||
} finally {
|
||||
togglingTask.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Fire one step now, without waiting for its time — the only way to find out
|
||||
// whether it will actually reach the charger before the night it matters. One
|
||||
// step rather than the whole flow: running a start and the stop that closes it
|
||||
// back to back would leave the charger where it began and prove nothing.
|
||||
//
|
||||
// The server takes the same path the clock takes, so what comes back is what
|
||||
// will happen then, errors included.
|
||||
async function runStep(task, index) {
|
||||
tasksError.value = "";
|
||||
runningTask.value = `${task.id}:${index}`;
|
||||
try {
|
||||
const res = await api.runChargingStep(task.id, index);
|
||||
// The row's own "last run" line is what reports this, so the answer is
|
||||
// folded into the record rather than announced somewhere else.
|
||||
const i = tasks.value.findIndex((x) => x.id === task.id);
|
||||
if (i >= 0) {
|
||||
tasks.value.splice(i, 1, {
|
||||
...task,
|
||||
lastRun: new Date().toISOString(),
|
||||
lastResult: res.summary || "",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
tasksError.value = e.message;
|
||||
} finally {
|
||||
runningTask.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTask(task) {
|
||||
tasksError.value = "";
|
||||
if (!(await askConfirm(t("charging.scheduler.removeConfirm", { name: task.name })))) return;
|
||||
try {
|
||||
await api.deleteChargingTask(task.id);
|
||||
tasks.value = tasks.value.filter((x) => x.id !== task.id);
|
||||
} catch (e) {
|
||||
tasksError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// What one step of a flow does. The ceiling is part of the sentence for the one
|
||||
// action that has one — "Set current limit" alone does not say to what.
|
||||
function stepActionLabel(step) {
|
||||
const label = t(`charging.scheduler.actions.${step.action}`);
|
||||
return step.action === "limit" ? `${label} · ${step.amps} A` : label;
|
||||
}
|
||||
|
||||
function taskChargersLabel(task) {
|
||||
const ids = task.chargers || [];
|
||||
if (ids.length === 0) return t("charging.scheduler.allChargers");
|
||||
const names = ids
|
||||
.map((id) => homeChargers.value.find((c) => c.id === id)?.name)
|
||||
.filter(Boolean);
|
||||
// A task can outlive a charger it names — the server skips the missing one
|
||||
// rather than failing, and the row says as much instead of showing a gap.
|
||||
if (names.length === 0) return t("charging.scheduler.missingChargers");
|
||||
return names.join(", ");
|
||||
}
|
||||
|
||||
function taskDaysLabel(task) {
|
||||
const days = task.days || [];
|
||||
if (days.length === 0) return t("charging.scheduler.everyDay");
|
||||
// Listed in the order this account reads a week in, so "Mon Fri" and the
|
||||
// picker that wrote it agree about which end of the week comes first.
|
||||
return sortWeekdays(days).map(weekdayShortName).join(" ");
|
||||
}
|
||||
|
||||
// A step's time, on the clock the user chose. It is stored as 24-hour "HH:MM"
|
||||
// — a schedule is a wall clock, not a moment, so there is no date to hand
|
||||
// formatTime — and this is the same reading TimeField offers when editing it.
|
||||
function stepClock(step) {
|
||||
const parts = /^(\d{1,2}):(\d{2})$/.exec(step?.time || "");
|
||||
if (!parts) return step?.time || "";
|
||||
const h = Number(parts[1]);
|
||||
if (!clockIsTwelveHour()) return step.time;
|
||||
return `${String(h % 12 || 12).padStart(2, "0")}:${parts[2]} ${h < 12 ? "am" : "pm"}`;
|
||||
}
|
||||
|
||||
// The tasks that will act on one charger — the ones that name it, plus every
|
||||
// task that names none, since those act on all of them.
|
||||
function tasksFor(charger) {
|
||||
return tasks.value.filter((task) => {
|
||||
const ids = task.chargers || [];
|
||||
return ids.length === 0 || ids.includes(charger.id);
|
||||
});
|
||||
}
|
||||
|
||||
// Whether the last firing went through, so the row can colour it. Unknown until
|
||||
// it has fired once — a task written this afternoon has nothing to report.
|
||||
function taskRunTone(task) {
|
||||
if (!task.lastRun) return "var(--text-muted)";
|
||||
// The server words the outcome as the step it fired and then "n of m sent",
|
||||
// plus the reasons when the two numbers differ. Every charger answering is
|
||||
// the only green case.
|
||||
const m = /(\d+) of (\d+) sent$/.exec(task.lastResult || "");
|
||||
return m && m[1] === m[2] ? TONE.good.fg : TONE.due.fg;
|
||||
}
|
||||
|
||||
// Opening the home tab is the moment reachability is being asked about; the
|
||||
// public half never needs it.
|
||||
// public half never needs it. The scheduler acts on the same chargers, so it
|
||||
// wants the list too — and its own tasks, once.
|
||||
watch(chargerTab, (tab) => {
|
||||
if (tab === "home") loadChargerLive();
|
||||
if (tab === "scheduler" && !tasksLoaded.value) loadChargingTasks();
|
||||
startLivePoll();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener("visibilitychange", onPageVisibility);
|
||||
await loadHomeChargers();
|
||||
await loadChargerProviders();
|
||||
if (chargerTab.value === "home") loadChargerLive();
|
||||
if (chargerTab.value === "scheduler") loadChargingTasks();
|
||||
startLivePoll();
|
||||
await loadCtlMode();
|
||||
if (ctlActive.value) await loadChargers();
|
||||
await refreshCtl();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopLivePoll();
|
||||
stopSettingsRetry();
|
||||
document.removeEventListener("visibilitychange", onPageVisibility);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1929,14 +2324,34 @@ onMounted(async () => {
|
||||
|
||||
<div v-show="isOpen('control')">
|
||||
|
||||
<!-- Which charger these buttons act on. The card said nothing about
|
||||
that before: the name is two cards further down, and the picture
|
||||
is the fastest way to tell two chargers on one account apart. -->
|
||||
<div
|
||||
v-if="ctlImageUrl || ctlChargerName"
|
||||
class="mt-3 flex items-center gap-3 rounded-control bg-sunken p-3"
|
||||
>
|
||||
<img
|
||||
v-if="ctlImageUrl && !imageFailed[ctlImageUrl]"
|
||||
:src="ctlImageUrl"
|
||||
alt=""
|
||||
class="h-16 w-16 shrink-0 rounded object-contain"
|
||||
@error="imageFailed[ctlImageUrl] = true"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p v-if="ctlChargerName" class="truncate text-sm font-semibold text-strong">{{ ctlChargerName }}</p>
|
||||
<p class="data truncate text-[11px] text-muted">{{ ctlSerial }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||
<div class="rounded-control bg-sunken px-3 py-2">
|
||||
<div class="data text-sm font-semibold text-strong">{{ ctlStatusLabel }}</div>
|
||||
<div class="text-[11px] text-muted">{{ t("charging.control.status") }}</div>
|
||||
<div class="text-[11px] text-muted">{{ ctlStatusTitle }}</div>
|
||||
</div>
|
||||
<div class="rounded-control bg-sunken px-3 py-2">
|
||||
<div class="data text-sm font-semibold text-strong">{{ ctlMeterKwh }} kWh</div>
|
||||
<div class="text-[11px] text-muted">{{ t("charging.control.meter") }}</div>
|
||||
<div class="text-[11px] text-muted">{{ ctlMeterTitle }}</div>
|
||||
</div>
|
||||
<div v-if="ctlPower" class="rounded-control bg-sunken px-3 py-2">
|
||||
<div class="data text-sm font-semibold text-strong">{{ ctlPower }}</div>
|
||||
@@ -1968,14 +2383,18 @@ onMounted(async () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The limit, everywhere but the local path. There the settings card
|
||||
owns it: it is the charger's own ceiling register, read back and
|
||||
written, and the same slider in two cards was the same register
|
||||
twice. Neither OCPP nor the cloud has a settings card to move it to
|
||||
— a charging profile is not a setting the charger reports — so here
|
||||
it stays. Clearing it is OCPP's alone: the cloud sets a ceiling and
|
||||
has no message for "no ceiling". -->
|
||||
<div v-if="!ctlIsModbus" class="mt-3">
|
||||
<!-- The limit, only where nothing else owns it. Both transports that
|
||||
read the charger have a settings card now, and on both the limit is
|
||||
the charger's own ceiling — the same register over Modbus, the same
|
||||
wire field over the cloud, where "limit" and the maxCurrentA
|
||||
setting are built from one table. The same slider in two cards was
|
||||
that one value twice.
|
||||
|
||||
OCPP is the exception and keeps it: a charging profile is not a
|
||||
setting the charger reports, so there is no settings card to move
|
||||
it to. Clearing it is OCPP's alone too — the cloud sets a ceiling
|
||||
and has no message for "no ceiling". -->
|
||||
<div v-if="!ctlReadsDevice" class="mt-3">
|
||||
<label class="dh-label flex justify-between">
|
||||
<span>{{ t("charging.control.limit") }}</span><span class="data text-body">{{ limitAmps }} A</span>
|
||||
</label>
|
||||
@@ -2219,7 +2638,22 @@ onMounted(async () => {
|
||||
>
|
||||
{{ rfidBusy === "scan" ? t("charging.rfid.tapping", { n: rfidCountdown }) : t("charging.rfid.tap") }}
|
||||
</button>
|
||||
<p v-if="rfidBusy === 'scan'" class="mt-1 text-[11px] text-muted">{{ t("charging.rfid.tapHint") }}</p>
|
||||
<!-- The same tap without the second press. Primary, because it is
|
||||
the one somebody standing at the charger wants; the button
|
||||
above stays for the times the number is wanted without the
|
||||
card being added. -->
|
||||
<button
|
||||
type="button"
|
||||
class="dh-btn dh-btn-primary mt-2 w-full text-xs"
|
||||
:disabled="rfidBusy !== ''"
|
||||
@click="tapAndSaveRfidCard"
|
||||
>
|
||||
{{ rfidBusy === "tapSave" ? t("charging.rfid.tapping", { n: rfidCountdown }) : t("charging.rfid.tapSave") }}
|
||||
</button>
|
||||
<p v-if="rfidBusy === 'scan' || rfidBusy === 'tapSave'" class="mt-1 text-[11px] text-muted">
|
||||
{{ t("charging.rfid.tapHint") }}
|
||||
</p>
|
||||
<p v-else class="mt-1 text-[11px] text-muted">{{ t("charging.rfid.tapSaveHint") }}</p>
|
||||
</form>
|
||||
<p class="mt-3 text-[11px] text-muted">{{ t("charging.rfid.inferred") }}</p>
|
||||
</div>
|
||||
@@ -2370,11 +2804,29 @@ onMounted(async () => {
|
||||
<section v-for="block in mqttSettingBlocks" :key="block.id" class="mt-2 rounded-control bg-sunken p-3">
|
||||
<h4 class="eyebrow">{{ t(`charging.modbus.${block.title}`) }}</h4>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="f in block.fields"
|
||||
:key="f.key || f.from"
|
||||
class="flex items-center justify-between gap-3"
|
||||
>
|
||||
<template v-for="f in block.fields" :key="f.key || f.from">
|
||||
<!-- A slider needs the width, so its row stacks: the label and
|
||||
the value it is at on one line, the track under them. -->
|
||||
<div v-if="f.type === 'slider'">
|
||||
<label class="dh-label flex justify-between" :for="`set-${f.key}`">
|
||||
<span>{{ t(`charging.modbus.${f.label}`) }}</span>
|
||||
<span class="data text-body">{{ mqttDraft[f.key] }} {{ f.unit }}</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`set-${f.key}`"
|
||||
v-model.number="mqttDraft[f.key]"
|
||||
type="range"
|
||||
:min="f.min"
|
||||
:max="fieldMax(f)"
|
||||
:step="f.step"
|
||||
class="w-full accent-[var(--accent)]"
|
||||
/>
|
||||
<p v-if="f.hint" class="mt-1 text-[11px] text-muted">
|
||||
{{ t(`charging.modbus.${f.hint}`, { amps: f.min }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-between gap-3">
|
||||
<label class="dh-label !mb-0 min-w-0 grow" :for="`set-${f.key || f.from}`">
|
||||
{{ t(`charging.modbus.${f.label}`) }}
|
||||
</label>
|
||||
@@ -2417,22 +2869,18 @@ onMounted(async () => {
|
||||
</span>
|
||||
|
||||
<span v-else class="flex shrink-0 items-center gap-1">
|
||||
<input
|
||||
:id="`set-${f.from}`"
|
||||
<TimeField
|
||||
v-model="mqttDraft[f.from]"
|
||||
type="time"
|
||||
class="dh-input w-28"
|
||||
:aria-label="`${t(`charging.modbus.${f.label}`)} — ${t('charging.modbus.windowStart')}`"
|
||||
/>
|
||||
<span class="text-[11px] text-muted">–</span>
|
||||
<input
|
||||
<TimeField
|
||||
v-model="mqttDraft[f.to]"
|
||||
type="time"
|
||||
class="dh-input w-28"
|
||||
:aria-label="`${t(`charging.modbus.${f.label}`)} — ${t('charging.modbus.windowEnd')}`"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-if="block.warning" class="mt-2 text-[11px]" style="color: var(--warning-600)">
|
||||
@@ -2976,10 +3424,171 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The scheduler: the user's own list of charging tasks, covering every
|
||||
charger on the account. The charger's own cloud schedule is one window
|
||||
inside one box; this is a list, and one list for all of them. -->
|
||||
<div v-show="chargerTab === 'scheduler'" class="grid gap-6 lg:grid-cols-[1fr_360px] lg:items-start">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="dh-card p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-strong">{{ t("charging.scheduler.title") }}</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("charging.scheduler.subtitle") }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="dh-btn dh-btn-primary shrink-0 !px-3 !py-1.5 text-xs"
|
||||
:disabled="homeChargers.length === 0"
|
||||
@click="newTask"
|
||||
>
|
||||
{{ t("charging.scheduler.add") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="tasksError" class="mt-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">
|
||||
{{ tasksError }}
|
||||
</p>
|
||||
|
||||
<!-- Nothing to act on yet: a task with no charger behind it would
|
||||
only ever report that it could not send anything. -->
|
||||
<p v-if="homeChargers.length === 0" class="mt-4 text-sm text-muted">
|
||||
{{ t("charging.scheduler.needCharger") }}
|
||||
</p>
|
||||
|
||||
<p v-else-if="tasks.length === 0" class="mt-4 text-sm text-muted">
|
||||
{{ t("charging.scheduler.empty") }}
|
||||
</p>
|
||||
|
||||
<!-- One row per task: what it does and when, which chargers, which
|
||||
days, and how the last firing went. -->
|
||||
<div v-else class="mt-3 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="rounded-control bg-sunken p-3"
|
||||
:class="task.enabled ? '' : 'opacity-60'"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-strong">{{ task.name }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ taskChargersLabel(task) }} · {{ taskDaysLabel(task) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- The switch. Written straight through, so what it shows is
|
||||
what the server will act on. It governs the whole flow:
|
||||
the task is one intention and is switched off as one. -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-pill px-3 py-1 text-xs font-semibold transition-colors disabled:opacity-50"
|
||||
:class="task.enabled ? 'bg-brand-600 text-white' : 'bg-card text-muted'"
|
||||
:disabled="togglingTask === task.id"
|
||||
:title="t('charging.scheduler.toggleHint')"
|
||||
@click="toggleTask(task)"
|
||||
>
|
||||
{{ task.enabled ? t("common.yes") : t("common.no") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The flow, a line per step. Read down, they are the night —
|
||||
which is the whole reason a task holds more than one. -->
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<div
|
||||
v-for="(s, i) in task.steps"
|
||||
:key="i"
|
||||
class="flex items-baseline gap-2 rounded-control px-2 py-1 transition-colors hover:bg-card"
|
||||
>
|
||||
<span class="data w-16 shrink-0 text-sm font-medium text-strong">{{ stepClock(s) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-xs text-body">{{ stepActionLabel(s) }}</span>
|
||||
<!-- Per step, because a flow is not a thing that can happen at
|
||||
once: firing a start and the stop that closes it back to
|
||||
back would leave the charger where it began. -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-[11px] font-medium text-muted transition-colors hover:text-body disabled:opacity-50"
|
||||
:disabled="runningTask === `${task.id}:${i}`"
|
||||
@click="runStep(task, i)"
|
||||
>
|
||||
{{ runningTask === `${task.id}:${i}` ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="task.lastRun" class="mt-2 text-[11px]" :style="{ color: taskRunTone(task) }">
|
||||
<!-- A middle dot rather than a dash: the outcome opens with the
|
||||
step's own time and a dash of its own, and three dashes in a
|
||||
row read as one long smudge. -->
|
||||
{{ t("charging.scheduler.lastRun", { when: formatDateTime(task.lastRun) }) }} ·
|
||||
{{ task.lastResult || t("charging.scheduler.noResult") }}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-3 border-t border-subtle pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-muted transition-colors hover:text-body"
|
||||
@click="editTask(task)"
|
||||
>
|
||||
{{ t("common.edit") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-muted transition-colors hover:text-danger"
|
||||
@click="removeTask(task)"
|
||||
>
|
||||
{{ t("common.remove") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-xs text-muted">{{ t("charging.scheduler.serverHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The same chargers the tab before this one lists, so the schedule can
|
||||
be read against the boxes it acts on without switching back. Each says
|
||||
how many tasks touch it, which is the question this column is here to
|
||||
answer. Read-only: this tab is about the schedule. -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="dh-card p-2">
|
||||
<div class="eyebrow px-3 pb-1.5 pt-2.5">
|
||||
{{ t("charging.stations.homeHeading") }} · {{ t("charging.home.count", { n: homeChargers.length }) }}
|
||||
</div>
|
||||
|
||||
<div v-for="c in homeChargers" :key="c.id" class="flex w-full items-center gap-3 rounded-control p-3 text-left">
|
||||
<span
|
||||
class="grid h-9 w-9 flex-none place-items-center rounded-control bg-sunken"
|
||||
:title="homeChargerStatusLabel(c)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" :stroke="homeChargerTone(c)" stroke-width="2" class="h-4.5 w-4.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-semibold text-strong">{{ c.name }}</span>
|
||||
<span class="data block truncate text-[11px] text-muted">{{ homeChargerSubtitle(c) }}</span>
|
||||
</span>
|
||||
<span class="shrink-0 text-[11px] text-muted">{{ t("charging.scheduler.taskCount", { n: tasksFor(c).length }) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="homeChargers.length === 0" class="px-3 pb-3 pt-1">
|
||||
<p class="text-sm text-muted">{{ t("charging.home.empty") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChargerImportModal
|
||||
v-if="showChargerImport"
|
||||
@saved="onChargerImported"
|
||||
@close="showChargerImport = false"
|
||||
/>
|
||||
|
||||
<ChargingTaskModal
|
||||
v-if="showTaskForm"
|
||||
:task="editingTask"
|
||||
:chargers="homeChargers"
|
||||
@saved="onTaskSaved"
|
||||
@close="showTaskForm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { state, isAdmin, logout, refreshProfile } from "../auth";
|
||||
import { prefs, applyProfilePrefs } from "../prefs";
|
||||
import { formatDate, formatMoney } from "../lib/format.js";
|
||||
import { formatDate, formatMoney, formatTime, weekdaysInOrder, weekdayShortName }
|
||||
from "../lib/format.js";
|
||||
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
|
||||
import { TAB_SURFACES, SETTINGS_TABS, defaultTabFor } from "../lib/tabs.js";
|
||||
import { askConfirm } from "../lib/confirm.js";
|
||||
@@ -189,7 +190,17 @@ function saveDefaultTab(surface, key) {
|
||||
}
|
||||
|
||||
const dateFormatExample = computed(() => formatDate(new Date().toISOString()));
|
||||
// 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.
|
||||
const timeFormatExample = computed(() => {
|
||||
const d = new Date();
|
||||
d.setHours(13, 45, 0, 0);
|
||||
return formatTime(d);
|
||||
});
|
||||
const currencyExample = computed(() => formatMoney(1234.5));
|
||||
// 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.
|
||||
const weekStartExample = computed(() => weekdaysInOrder().map(weekdayShortName).join(" "));
|
||||
|
||||
// Language and region are two controls over the one stored BCP-47 locale, so
|
||||
// the pair can be mixed freely (English in Poland, say) rather than being
|
||||
@@ -329,6 +340,21 @@ async function saveBio() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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?
|
||||
function inheritedPlaceholder(field, example = "") {
|
||||
return field.locked ? field.effective || "" : example;
|
||||
}
|
||||
|
||||
// --- Integrations: Toyota Connected (per-user, cascading settings) ---
|
||||
//
|
||||
// The server resolves a superadmin → org admin → user cascade and returns, per
|
||||
@@ -493,7 +519,7 @@ function showsIntegration(id) {
|
||||
|
||||
const anker = ref(null); // resolved view from the server
|
||||
const ankerScope = ref("user"); // "user" | "org" (org admins only)
|
||||
const ankerForm = ref({ email: "", password: "", country: "", controlMode: "off" });
|
||||
const ankerForm = ref({ email: "", password: "", country: "", controlMode: "off", controlModesDisabled: [] });
|
||||
const ankerSaving = ref(false);
|
||||
const ankerSaved = ref(false);
|
||||
const ankerError = ref("");
|
||||
@@ -522,17 +548,63 @@ function ankerSourceLabel(k) {
|
||||
return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) });
|
||||
}
|
||||
|
||||
// Clamp a stored mode to what this scope still offers.
|
||||
function ankerModeInScope(mode) {
|
||||
const modes = ankerScopeData.value.controlModes;
|
||||
if (!mode) return "off";
|
||||
if (modes?.length && !modes.includes(mode)) return "off";
|
||||
return mode;
|
||||
}
|
||||
|
||||
function fillAnkerForm() {
|
||||
const f = ankerScopeData.value.fields || {};
|
||||
ankerForm.value = {
|
||||
email: f.email?.locked ? "" : f.email?.own || "",
|
||||
password: "",
|
||||
country: f.country?.locked ? "" : f.country?.own || "",
|
||||
// controlMode isn't secret, so show the effective value when it's locked.
|
||||
controlMode: f.controlMode?.locked ? f.controlMode?.effective || "off" : f.controlMode?.own || "off",
|
||||
// controlMode isn't secret, so show the effective value when it's locked. A
|
||||
// mode the layers above have since hidden is no longer on offer, so the
|
||||
// picker starts from monitoring-only rather than showing a dead choice.
|
||||
controlMode: ankerModeInScope(
|
||||
f.controlMode?.locked ? f.controlMode?.effective : f.controlMode?.own,
|
||||
),
|
||||
// Which modes this organization hides from its own users (org scope only).
|
||||
controlModesDisabled: [...(ankerScopeData.value.controlModesDisabled || [])],
|
||||
};
|
||||
}
|
||||
|
||||
// The modes this scope may still pick: the server has already removed whatever
|
||||
// the layers above hid. An empty list would leave the picker blank, so fall back
|
||||
// to monitoring-only, which no layer can take away.
|
||||
const ankerControlModeOptions = computed(() => {
|
||||
const modes = ankerScopeData.value.controlModes;
|
||||
return modes?.length ? modes : ["off"];
|
||||
});
|
||||
// The modes an org admin may hide from their users — everything they can choose
|
||||
// themselves, minus off, which is the fallback.
|
||||
const ankerHideableModes = computed(() =>
|
||||
ankerControlModeOptions.value.filter((m) => m !== "off"),
|
||||
);
|
||||
const ankerModeLabels = {
|
||||
off: "controlOff",
|
||||
mqtt: "controlCloud",
|
||||
modbus: "controlModbus",
|
||||
own: "controlOwn",
|
||||
proxy: "controlProxy",
|
||||
};
|
||||
function ankerModeLabel(m) {
|
||||
return t("settings.integrations." + (ankerModeLabels[m] || "controlOff"));
|
||||
}
|
||||
function ankerHidesMode(m) {
|
||||
return ankerForm.value.controlModesDisabled.includes(m);
|
||||
}
|
||||
function toggleAnkerHiddenMode(m, on) {
|
||||
const set = new Set(ankerForm.value.controlModesDisabled);
|
||||
if (on) set.add(m);
|
||||
else set.delete(m);
|
||||
ankerForm.value.controlModesDisabled = ankerHideableModes.value.filter((v) => set.has(v));
|
||||
}
|
||||
|
||||
// The effective control mode (off | mqtt | modbus | own | proxy) — it gates the
|
||||
// control panel.
|
||||
const ankerControlMode = computed(() => anker.value?.controlMode || "off");
|
||||
@@ -691,6 +763,9 @@ async function saveAnkerSettings() {
|
||||
if (k === "password" && !ankerForm.value.password) continue;
|
||||
config[k] = ankerForm.value[k];
|
||||
}
|
||||
// The hide-list travels as the comma-separated string the server stores; only
|
||||
// a layer with users under it has one.
|
||||
if (ankerEditingOrg.value) config.controlModesDisabled = ankerForm.value.controlModesDisabled.join(",");
|
||||
try {
|
||||
applyAnkerView(await api.saveAnkerSolix({ scope: ankerScopeKey.value, config }));
|
||||
ankerSaved.value = true;
|
||||
@@ -1127,6 +1202,30 @@ onBeforeUnmount(() => {
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Beside the date rather than under the region, because it is the
|
||||
same question asked about the other half of a timestamp. -->
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.appearance.timeFormat") }}</label>
|
||||
<select :value="prefs.timeFormat" class="dh-input" @change="saveAppearance({ timeFormat: $event.target.value })">
|
||||
<option value="auto">{{ t("settings.appearance.timeAuto") }}</option>
|
||||
<option value="24">{{ t("settings.appearance.time24") }}</option>
|
||||
<option value="12">{{ t("settings.appearance.time12") }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.timeExample", { example: timeFormatExample }) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Under the clock, as the last of the three questions a region is
|
||||
asked and the one it is least often asked out loud. -->
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.appearance.weekStart") }}</label>
|
||||
<select :value="prefs.weekStart" class="dh-input" @change="saveAppearance({ weekStart: $event.target.value })">
|
||||
<option value="auto">{{ t("settings.appearance.weekAuto") }}</option>
|
||||
<option value="monday">{{ t("settings.appearance.weekMonday") }}</option>
|
||||
<option value="sunday">{{ t("settings.appearance.weekSunday") }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.weekExample", { example: weekStartExample }) }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
|
||||
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
|
||||
@@ -1542,7 +1641,7 @@ onBeforeUnmount(() => {
|
||||
v-model="ankerForm.country"
|
||||
class="dh-input"
|
||||
:disabled="ankerLocked('country')"
|
||||
placeholder="DE"
|
||||
:placeholder="inheritedPlaceholder(ankerField('country'), 'DE')"
|
||||
maxlength="2"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@@ -1552,15 +1651,33 @@ onBeforeUnmount(() => {
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.integrations.controlMode") }}</label>
|
||||
<select v-model="ankerForm.controlMode" class="dh-input" :disabled="ankerLocked('controlMode')">
|
||||
<option value="off">{{ t("settings.integrations.controlOff") }}</option>
|
||||
<option value="mqtt">{{ t("settings.integrations.controlCloud") }}</option>
|
||||
<option value="modbus">{{ t("settings.integrations.controlModbus") }}</option>
|
||||
<option value="own">{{ t("settings.integrations.controlOwn") }}</option>
|
||||
<option value="proxy">{{ t("settings.integrations.controlProxy") }}</option>
|
||||
<option v-for="m in ankerControlModeOptions" :key="m" :value="m">{{ ankerModeLabel(m) }}</option>
|
||||
</select>
|
||||
<p v-if="ankerField('controlMode').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('controlMode') }}</p>
|
||||
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModeHint") }}</p>
|
||||
</div>
|
||||
<!-- An org admin decides which of the modes left to them their own
|
||||
users get to see. Modes the superadmin already hid are not in
|
||||
the list at all, so they cannot be handed back. -->
|
||||
<div v-if="ankerEditingOrg && ankerHideableModes.length">
|
||||
<label class="dh-label">{{ t("settings.integrations.controlModesHidden") }}</label>
|
||||
<div class="flex flex-col gap-1.5 pt-1">
|
||||
<label
|
||||
v-for="m in ankerHideableModes"
|
||||
:key="m"
|
||||
class="flex items-center gap-2 text-sm text-body"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="dh-checkbox"
|
||||
:checked="ankerHidesMode(m)"
|
||||
@change="toggleAnkerHiddenMode(m, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ ankerModeLabel(m) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModesHiddenHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
@@ -1806,7 +1923,7 @@ onBeforeUnmount(() => {
|
||||
v-model="greencellForm.port"
|
||||
class="dh-input"
|
||||
:disabled="greencellLocked('port')"
|
||||
placeholder="1883"
|
||||
:placeholder="inheritedPlaceholder(greencellField('port'), '1883')"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@@ -1859,7 +1976,7 @@ onBeforeUnmount(() => {
|
||||
v-model="greencellForm.serial"
|
||||
class="dh-input"
|
||||
:disabled="greencellLocked('serial')"
|
||||
placeholder="EVGC021B22752405ZM0018"
|
||||
:placeholder="inheritedPlaceholder(greencellField('serial'), 'EVGC021B22752405ZM0018')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p v-if="greencellField('serial').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('serial') }}</p>
|
||||
@@ -1871,7 +1988,7 @@ onBeforeUnmount(() => {
|
||||
v-model="greencellForm.timeout"
|
||||
class="dh-input"
|
||||
:disabled="greencellLocked('timeout')"
|
||||
placeholder="12"
|
||||
:placeholder="inheritedPlaceholder(greencellField('timeout'), '12')"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@@ -1884,7 +2001,7 @@ onBeforeUnmount(() => {
|
||||
v-model="greencellForm.commandTopic"
|
||||
class="dh-input"
|
||||
:disabled="greencellLocked('commandTopic')"
|
||||
placeholder="/greencell/evse/{sn}/command"
|
||||
:placeholder="inheritedPlaceholder(greencellField('commandTopic'), '/greencell/evse/{sn}/command')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p v-if="greencellField('commandTopic').locked" class="mt-1 text-xs text-muted">{{ greencellSourceLabel('commandTopic') }}</p>
|
||||
|
||||
Reference in New Issue
Block a user