v0.275.0: a backup's data and its version travel together (R-696, 07 §6.6, D4 option A); R-695, R-691, R-694
gates / gates (push) Successful in 23s

The unit's data files are stamped with the versions that wrote them; the capture keeps the
definition the data belongs to; a restore never starts data under another version's
definition (unit restores refuse a mismatch; the off-site restore writes the snapshot's
definition); every tier's time is its data's; the conversion-copy release needs a dump on
the new engine. File-browser sync single-flight + no empty kept folder (R-695); the kept
view joins the folder's owning group, language switch resyncs (R-691); a restore-generated
login is not shown as the password (R-694). Red-proofs in
felhom.eu/documentation/audits/version-travel-2026-09-26/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-26 10:35:22 +02:00
parent fb2bcdd5d6
commit b6810f14ff
47 changed files with 3771 additions and 135 deletions
@@ -0,0 +1,48 @@
package web
import (
"strings"
"testing"
"time"
)
// TestA3_RestoredVersionSentence — the FIRST sentence after a restore of an older version (v0.275.0,
// `07` §6.6, D4 option A), both languages, and its absence when nothing changed. The brief's wording is
// the contract: which backup, which version, and what happens next.
func TestA3_RestoredVersionSentence(t *testing.T) {
s := noteServer(t)
at := time.Date(2026, 9, 26, 7, 29, 45, 0, time.UTC)
pins := []string{"docmost/docmost:0.96.0@sha256:b5", "postgres:16-alpine", "redis:7-alpine"}
s.versionPosition = func(string) (bool, bool) { return true, true }
got := s.restoredVersionPrefix("docmost", true, pins, at)
for _, want := range []string{"A(z) docmost visszaállt a(z) 2026-09-26 ", "-i mentésből", "docmost:0.96.0, postgres:16-alpine, redis:7-alpine verzióra", "lépésenként hozza naprakészre"} {
if !strings.Contains(got, want) {
t.Errorf("hu: missing %q in %q", want, got)
}
}
if !strings.HasSuffix(got, " ") {
t.Errorf("the prefix must end with a space before the outcome sentence: %q", got)
}
if err := s.settings.SetLanguage("en"); err != nil {
t.Fatal(err)
}
got = s.restoredVersionPrefix("docmost", true, pins, at)
if !strings.Contains(got, "docmost is back from the backup of 2026-09-26 ") || !strings.Contains(got, "one step at a time") {
t.Errorf("en: %q", got)
}
// No tested step leads on from the restored version: say the box will not update it by itself.
s.versionPosition = func(string) (bool, bool) { return true, false }
if got := s.restoredVersionPrefix("docmost", true, pins, at); !strings.Contains(got, "does not update it by itself") {
t.Errorf("no ladder step: %q", got)
}
// Nothing changed, or the app is not behind: no sentence.
if got := s.restoredVersionPrefix("docmost", false, pins, at); got != "" {
t.Errorf("unchanged version: %q", got)
}
s.versionPosition = func(string) (bool, bool) { return false, false }
if got := s.restoredVersionPrefix("docmost", true, pins, at); got != "" {
t.Errorf("not behind: %q", got)
}
}
+87 -14
View File
@@ -487,7 +487,17 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
data["AutoFieldValues"] = autoFieldValues
// For deployed apps, pass stored field values (decrypted) so fields show current values
if alreadyDeployed && decryptedEnv != nil {
// R-694 (v0.275.0): a login a restore GENERATED is not the app's login (its login came back with
// the data) — its value is never rendered, and the field says to use the old password.
restored := map[string]bool{}
if appCfg != nil {
for _, n := range appCfg.RestoredLogins {
restored[n] = true
delete(decryptedEnv, n)
}
}
data["DeployedFieldValues"] = decryptedEnv
data["RestoredLogins"] = restored
}
// R-351 SCENARIO A — an app being reinstalled so its data can come back should not ask the
// customer to remember what their own backup already recorded. The address and the data folder
@@ -1723,7 +1733,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
// The customer reads it as "my data is back". The snapshot id is dropped from the sentence
// deliberately: it identified WHICH backup ran and told the customer nothing about what came out
// of it, which is the question the sentence exists to answer.
s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res))
s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(stackName, res.VersionChanged, res.DataPins, res.DataAt)+s.unitRestoreOutcomeMsg(stackName, res))
}()
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
@@ -1780,6 +1790,39 @@ func (s *Server) unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult)
return s.note(unitRestoreSettingsOnlyKey, app)
}
// restoredVersionPrefix is the FIRST sentence after a restore that brought an app back at an older
// version (v0.275.0, `07` §6.6 "Which version a restore brings back", D4 option A): which backup, which
// version, and what happens next — the box climbs it one tested step at a time, or, when no tested step
// leads on from that version, that the box will not update it by itself (a person's press would jump to
// the catalog's current definition, which is not a tested step). "" when the version did not change or
// the app is not behind. Pinned by TestA3_RestoredVersionSentence.
func (s *Server) restoredVersionPrefix(app string, changed bool, pins []string, at time.Time) string {
if !changed || len(pins) == 0 {
return ""
}
pos := s.versionPosition
if pos == nil {
if s.stackMgr == nil {
return ""
}
pos = s.stackMgr.RestoredVersionPosition
}
behind, climbable := pos(app)
if !behind {
return ""
}
key := restoredOlderVersionKey
if !climbable {
key = restoredOlderVersionNoStepKey
}
return s.note(key, app, at.In(getTimezone()).Format("2006-01-02 15:04"), backup.PinsVersion(pins)) + " "
}
const (
restoredOlderVersionKey = "note.restore.older_version"
restoredOlderVersionNoStepKey = "note.restore.older_version_no_step"
)
// R-353 customer-facing strings. Named constants, not inlined, because each is asserted verbatim by
// r353_unit_outcome_test.go — a silent edit to any of them is how an honest message drifts back into a
// comforting one, which is the exact history of the sentence they replace.
@@ -2082,7 +2125,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
s.logger.Printf("[INFO] [web] Tier-2 whole restore completed (async): stack=%s in %s (files restored %d, replaced %d, kept-newer %d, unchanged %d; volumes %d/%d, dbs %d/%d)",
stackName, time.Since(start), res.Files.Restored, res.Files.Replaced, res.Files.KeptNewer, res.Files.Unchanged,
res.Unit.VolumesReplayed, res.Unit.ManifestVolumes, res.Unit.DBsReplayed, res.Unit.ManifestDBs)
s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res.Unit)+" "+files)
s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(stackName, res.Unit.VersionChanged, res.Unit.DataPins, res.Unit.DataAt)+s.unitRestoreOutcomeMsg(stackName, res.Unit)+" "+files)
}()
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash", "flash.restore.full_started"), http.StatusFound)
return
@@ -2120,7 +2163,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
// second thing to keep honest. What IS added is which copy it came from and how old that copy
// is: this action overwrote the customer's live data, and the sentence they are left with has
// to say what it overwrote it with (Scenario E).
msg := s.unitRestoreOutcomeMsg(stackName, res)
msg := s.restoredVersionPrefix(stackName, res.VersionChanged, res.DataPins, res.DataAt) + s.unitRestoreOutcomeMsg(stackName, res)
if src := s.tier2UnitSourceMsg(cov); src != "" {
msg += " " + src
}
@@ -3350,10 +3393,43 @@ func skipFileBrowserPath(path string, isMount func(string) bool) bool {
}
func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
// R-695 (v0.275.0): SINGLE-FLIGHT. A request is covered by any sync that STARTS reading the state
// after the request was made: a caller that waited behind a running sync returns as soon as one
// such sync has finished, instead of each queued caller restarting the file browser again. Measured
// 2026-09-25 on 9202: two kept-data Deletes in the same second each ran a sync; one restarted the
// file browser with a bind list read before the second Delete, Docker recreated the deleted folder
// empty, and the next sync listed and bound it again.
s.fbReqMu.Lock()
s.fbReqGen++
mine := s.fbReqGen
s.fbReqMu.Unlock()
// Prevent concurrent syncs — multiple callers can race on the same files (H5 fix).
s.fileBrowserMu.Lock()
defer s.fileBrowserMu.Unlock()
s.fbReqMu.Lock()
if !resetDBOnChange && s.fbDoneGen >= mine {
s.fbReqMu.Unlock()
if s.cfg != nil && s.isDebug() {
s.logger.Printf("[DEBUG] [web] FileBrowser sync request %d already covered by a sync that read the state after it", mine)
}
return
}
covers := s.fbReqGen // every request made before THIS read of the state is covered by this sync
s.fbReqMu.Unlock()
defer func() {
s.fbReqMu.Lock()
if covers > s.fbDoneGen {
s.fbDoneGen = covers
}
s.fbReqMu.Unlock()
}()
if s.syncFileBrowserHook != nil {
s.syncFileBrowserHook()
return
}
stackDir := "/opt/docker/stacks/filebrowser"
composePath := stackDir + "/docker-compose.yml"
@@ -3407,9 +3483,13 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
// `09` §3 decision 36: the read-only „Megőrzött adatok" source — one `:ro` bind per kept item, and
// the source only when there is at least one (a source with no mount is a broken sidebar entry, R-67).
keptLabel := ""
if kb := s.keptFileBrowserBinds(); len(kb) > 0 {
storageMounts = append(storageMounts, kb...)
keptLabel = s.msgLang(s.boxLang(), "kept.fb_source")
var keptGroups []int
if s.stackMgr != nil {
if items := s.stackMgr.ListKept(s.keptDrives()); len(items) > 0 {
storageMounts = append(storageMounts, keptBindLines(items)...)
keptLabel = s.msgLang(s.boxLang(), "kept.fb_source")
keptGroups = keptReadGroups(items, statOwner)
}
}
configPath := stackDir + "/config.yaml"
@@ -3436,7 +3516,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
}
// Generate and write compose (includes config.yaml mount)
compose := generateFileBrowserCompose(domain, storageMounts)
compose := infra.RenderFileBrowserCompose(domain, storageMounts, keptGroups...)
if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil {
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser compose: %v", err)
return
@@ -3574,13 +3654,6 @@ func fbNeedsRecreate(oldConfig, newConfig, oldCompose, newCompose []byte) bool {
return !bytes.Equal(oldConfig, newConfig) || !bytes.Equal(oldCompose, newCompose)
}
// generateFileBrowserCompose returns a FileBrowser docker-compose.yml string with the given domain
// and storage volume-mount lines. Delegates to internal/infra (the single source of truth — so the
// pinned image and the base-infra bring-up path can never diverge).
func generateFileBrowserCompose(domain string, storageMounts []string) string {
return infra.RenderFileBrowserCompose(domain, storageMounts)
}
// generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per
// registered storage path. Delegates to internal/infra (single source of truth).
func generateFileBrowserConfig(paths []settings.StoragePath, importSource bool) string {
@@ -175,6 +175,13 @@ func i18nCasesA() []i18nCase {
{"deploy_deployed_stopped", "deploy", func() map[string]interface{} {
return i18nDeployData(true, stacks.StateStopped, 2)
}},
// v0.275.0 (R-694): a login the restore generated — no value, the "use your old password" hint.
{"deploy_deployed_restored_login", "deploy", func() map[string]interface{} {
d := i18nDeployData(true, stacks.StateRunning, 1)
d["DeployedFieldValues"] = map[string]string{"SUBDOMAIN": "paste"}
d["RestoredLogins"] = map[string]bool{"ADMIN_PASSWORD": true}
return d
}},
{"settings_system_full", "settings_system", func() map[string]interface{} {
d := i18nLayoutData("settings", "Rendszer beállítások")
d["CustomerID"], d["CustomerDomain"] = "test-customer", "example.hu"
+8
View File
@@ -270,6 +270,14 @@ func (s *Server) languageSwitchHandler(w http.ResponseWriter, r *http.Request) {
}
s.logger.Printf("[INFO] [web] language switch: household language set to %s", lang)
s.reportTriggerNow() // the hub learns the language on the next report, in seconds rather than minutes
// R-691 (v0.275.0): the file browser's „Megőrzött adatok" / "Kept data" source is named in the box's
// language when the sync writes its config — so the sync runs now, not at the next unrelated change.
// A no-op when the box keeps no data (the source is absent) and when nothing changed.
if s.langSwitchSync != nil {
s.langSwitchSync()
} else {
go s.SyncFileBrowserMounts()
}
http.Redirect(w, r, stripLangQuery(redirectBackTo(r, "/launcher")), http.StatusFound)
}
+40 -9
View File
@@ -3,8 +3,11 @@ package web
import (
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
@@ -190,15 +193,6 @@ func (s *Server) keptLoadHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
// keptFileBrowserBinds are the read-only binds of the „Megőrzött adatok" source: one per listed item,
// `:ro`, under /srv/<source dir> — never a live app's folder (ListKept never lists one).
func (s *Server) keptFileBrowserBinds() []string {
if s.stackMgr == nil {
return nil
}
return keptBindLines(s.stackMgr.ListKept(s.keptDrives()))
}
// keptBindLines renders the compose bind lines — each READ-ONLY. Pinned by TestKept_FileBrowserBindsAreReadOnly.
func keptBindLines(items []stacks.KeptItem) []string {
var out []string
@@ -207,3 +201,40 @@ func keptBindLines(items []stacks.KeptItem) []string {
}
return out
}
// keptReadGroups is the R-691 rule — decided by CC unattended 2026-09-26, operator may reverse (`07` §6.5):
// the read-only view reads a kept folder another user owns by joining that folder's OWNING GROUP, never by
// changing the household's files or their permissions (nextcloud checks its data folder's mode after a
// Load). A group is added only when the folder is group-READABLE and the group is neither root's (0 — it
// would reach every root-group file in the view's other mounts) nor the view's own (1000). The kept binds
// are `:ro`, so the added group cannot write kept data. Sorted, unique. Pinned by TestR691_KeptReadGroups.
func keptReadGroups(items []stacks.KeptItem, owner func(path string) (gid int, mode os.FileMode, ok bool)) []int {
seen := map[int]bool{}
var out []int
for _, it := range items {
gid, mode, ok := owner(it.Path)
if !ok || gid == 0 || gid == fileBrowserUID || mode&0o040 == 0 || seen[gid] {
continue
}
seen[gid] = true
out = append(out, gid)
}
sort.Ints(out)
return out
}
// fileBrowserUID is the uid:gid the file-browser image runs as (gtstef/filebrowser: `filebrowser`, 1000).
const fileBrowserUID = 1000
// statOwner is keptReadGroups' production owner reader.
func statOwner(path string) (int, os.FileMode, bool) {
fi, err := os.Stat(path)
if err != nil {
return 0, 0, false
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return 0, 0, false
}
return int(st.Gid), fi.Mode().Perm(), true
}
+1 -1
View File
@@ -471,7 +471,7 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
}
s.logger.Printf("[INFO] [web] off-box reconstitute %s completed (async): files=%d dbs=%d snapshot=%s",
app, res.FilesPlaced, res.DBsReplayed, res.SnapshotID)
s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res, s.boxLang()))
s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(app, res.VersionChanged, res.DataPins, res.DataAt)+reconstituteOutcomeMsg(app, res, s.boxLang()))
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.full_restore_started", false)
}
@@ -0,0 +1,70 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-691 (v0.275.0) — the read-only „Megőrzött adatok" view could not open nextcloud's kept folder
// (`www-data` 33, mode 0770; the view runs as 1000). The rule: join the folder's OWNING GROUP when it
// is group-readable — never root's, never the view's own — and change nothing on the household's files.
//
// COMPANION RED-PROOF (REPORT.md): return nil from keptReadGroups — the compose then carries no
// group_add and this fails at "group_add missing".
func TestR691_KeptReadGroups(t *testing.T) {
owners := map[string]struct {
gid int
mode os.FileMode
}{
"/d/kept/nextcloud/2026-09-25_141014": {33, 0o770}, // the measured case → 33
"/d/appdata/nextcloud": {33, 0o770}, // the same group once
"/d/kept/romm/x": {1000, 0o755}, // the view's own group: nothing to add
"/d/kept/secret/x": {0, 0o750}, // root's group: NEVER
"/d/kept/private/x": {999, 0o700}, // not group-readable: adding it would not help
"/d/kept/jellyfin/x": {911, 0o750}, // another readable group → 911
}
var items []stacks.KeptItem
for p := range owners {
items = append(items, stacks.KeptItem{Path: p})
}
got := keptReadGroups(items, func(p string) (int, os.FileMode, bool) {
o, ok := owners[p]
return o.gid, o.mode, ok
})
if len(got) != 2 || got[0] != 33 || got[1] != 911 {
t.Fatalf("groups = %v, want [33 911]", got)
}
compose := infra.RenderFileBrowserCompose("example.hu", keptBindLines([]stacks.KeptItem{{Path: "/d/kept/nextcloud/2026-09-25_141014", App: "nextcloud"}}), got...)
if !strings.Contains(compose, "group_add:\n - \"33\"\n - \"911\"") {
t.Fatalf("group_add missing or malformed:\n%s", compose)
}
if !strings.Contains(compose, ":/srv/"+infra.FileBrowserKeptMount+"/") || !strings.Contains(compose, ":ro") {
t.Fatalf("the kept bind must stay read-only:\n%s", compose)
}
// No kept groups → the compose is exactly what it was before v0.275.0.
if strings.Contains(infra.RenderFileBrowserCompose("example.hu", nil), "group_add") {
t.Fatal("a box with no kept data got a group_add")
}
}
// The source's name follows the box's language: switching the language triggers the file-browser sync
// that writes it (before, it waited for the next unrelated change — seen on 9202 2026-09-25).
func TestR691_ALanguageSwitchResyncsTheFileBrowser(t *testing.T) {
s := noteServer(t)
called := 0
s.langSwitchSync = func() { called++ }
req := httptest.NewRequest(http.MethodPost, "/settings/language", strings.NewReader(url.Values{"lang": {"en"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
s.languageSwitchHandler(rec, req)
if rec.Code != http.StatusFound || called != 1 {
t.Fatalf("status %d, file-browser syncs %d — want a redirect and one sync", rec.Code, called)
}
}
@@ -0,0 +1,34 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-694 (v0.275.0) — a deployed app whose admin login was GENERATED by a restore: the page shows no value
// for it and says the old password is the one that works; an app without that record renders as before.
// Rendered through the production template path (renderI18nCase), both languages.
func TestR694_TheDeployPageDoesNotOfferAGeneratedLogin(t *testing.T) {
s := i18nTestServer(t)
base := func() map[string]interface{} { return i18nDeployData(true, stacks.StateRunning, 1) }
restored := func() map[string]interface{} {
d := base()
d["DeployedFieldValues"] = map[string]string{"SUBDOMAIN": "paste"} // the handler removed the value
d["RestoredLogins"] = map[string]bool{"ADMIN_PASSWORD": true}
return d
}
hu := renderI18nCase(t, s, "hu", i18nCase{"r694", "deploy", restored})
if !strings.Contains(hu, "Mentésből töltötted vissza") || strings.Contains(hu, "rejtett érték") || strings.Contains(hu, "Telepítéskor beállított kezdeti jelszó") {
t.Fatalf("hu page does not say to use the old password, or still shows a value")
}
en := renderI18nCase(t, s, "en", i18nCase{"r694", "deploy", restored})
if !strings.Contains(en, "Restored from a backup: log in with the password that was valid when the backup was taken") {
t.Fatal("en page lacks the sentence")
}
plain := renderI18nCase(t, s, "hu", i18nCase{"r694-plain", "deploy", base})
if strings.Contains(plain, "Mentésből töltötted vissza") || !strings.Contains(plain, "Telepítéskor beállított kezdeti jelszó") {
t.Fatal("an app without restored_logins changed")
}
}
@@ -0,0 +1,53 @@
package web
import (
"io"
"log"
"sync"
"sync/atomic"
"testing"
"time"
)
// R-695 (v0.275.0) — the file-browser sync is single-flight: callers that queued behind a running sync
// are covered by ONE sync that read the state after they asked, not by one restart each; a caller whose
// request came after a sync began reading is never dropped.
//
// COMPANION RED-PROOF (REPORT.md): remove the `fbDoneGen >= mine` early return — five queued callers
// then run five syncs.
func TestR695_QueuedSyncsCoalesceIntoOne(t *testing.T) {
s := &Server{logger: log.New(io.Discard, "", 0)}
var runs int32
release := make(chan struct{})
first := true
var fmu sync.Mutex
s.syncFileBrowserHook = func() {
atomic.AddInt32(&runs, 1)
fmu.Lock()
f := first
first = false
fmu.Unlock()
if f {
<-release // the first sync is slow: everything below queues behind it
}
}
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); s.SyncFileBrowserMounts() }()
time.Sleep(50 * time.Millisecond) // the first sync holds the lock
for i := 0; i < 5; i++ {
wg.Add(1)
go func() { defer wg.Done(); s.SyncFileBrowserMounts() }()
}
time.Sleep(50 * time.Millisecond) // all five are queued
close(release)
wg.Wait()
if got := atomic.LoadInt32(&runs); got != 2 {
t.Fatalf("%d syncs ran for 6 requests (1 running + 5 queued) — want 2: the running one and ONE that re-reads for the queue", got)
}
// A request after everything finished is never dropped.
s.SyncFileBrowserMounts()
if got := atomic.LoadInt32(&runs); got != 3 {
t.Fatalf("a new request after the queue drained ran %d syncs in total, want 3", got)
}
}
+13
View File
@@ -51,6 +51,10 @@ type Server struct {
tmplByLang map[string]*template.Template
i18n *i18n.Bundle
// versionPosition (v0.275.0) — where a just-restored app stands against the catalog; nil → the stack
// manager's RestoredVersionPosition. A seam so the restore sentence is testable without a catalog.
versionPosition func(app string) (behind, climbable bool)
sessions map[string]*session
sessionsMu sync.RWMutex
loginAttempts map[string]*loginAttempt
@@ -73,6 +77,15 @@ type Server struct {
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
fileBrowserMu sync.Mutex
// fbReqMu guards the file-browser sync's single-flight counters (R-695, v0.275.0): fbReqGen counts
// requests, fbDoneGen is the newest request a finished sync is known to have covered.
fbReqMu sync.Mutex
fbReqGen uint64
fbDoneGen uint64
// syncFileBrowserHook replaces the sync's body in tests (the single-flight is the unit under test).
syncFileBrowserHook func()
// langSwitchSync replaces the language switch's file-browser sync in tests (R-691).
langSwitchSync func()
// Shared agent local-API client (built once, reused). cfg.LocalAPI is static per process (a
// config-apply triggers a graceful self-restart), so the client is memoized via agentCliOnce —
@@ -577,7 +577,7 @@
{{end}}
</div>
{{if $.AlreadyDeployed}}
<span class="form-hint">{{T "deploy.telepiteskor_beallitott_kezdeti_jelszo_h"}}</span>
<span class="form-hint">{{if and $.RestoredLogins (index $.RestoredLogins .EnvVar)}}{{T "deploy.login_from_backup"}}{{else}}{{T "deploy.telepiteskor_beallitott_kezdeti_jelszo_h"}}{{end}}</span>
{{else}}
<div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-{{.EnvVar}}"
File diff suppressed because it is too large Load Diff