v0.147.0 — feedback slice 1: pressing a button says something
The systemic complaint, twice in one evening: you press a button and nothing happens. No progress, no ETA, no named result. Three worst offenders, fixed on the two patterns already here (deploy 3-step panel, storage-init status poll). No new framework — that is a ROADMAP item; three targeted cards ship tonight. 4a — a verification restore names its result. The flash said the app had been restored "to a verification folder on the drive"; which folder, on which drive, was invisible, so the customer could not go and look at what they had just asked for. Full path now. The restore page gained a listing of existing verification copies (app, size, date, path) — nothing anywhere showed these, so they piled up and the only way to find them was SSH — each with a double-confirmed delete. That delete is the only one this release adds, so it names a STACK, never a path: the Manager resolves the name inside a backups/offsite-restore root it computed itself and refuses anything landing outside. Red-proofed — neutralise the name guard and stack:"" resolves to the offsite-restore ROOT and takes every copy with it. Refusals are asserted as non-effects. 4b — Megosztás enable shows what it is waiting for. Enabling ran ReconcileSamba synchronously inside the POST handler; on a golden without felhom-samba baked that is compose pulling ~100MB, i.e. minutes of an apparently-hung form post followed by "Beállítás mentve." whether or not anything came up. Detached + polled now, distinguishing "képfájl letöltése" from "indítás" — decided BEFORE the work starts, since afterwards the image is always present. Success is probed, not inferred (compose up -d exits 0 on a crash-loop). The password form starts the same job: with UserSet false reconcile deploys nothing, so on a fresh box that is where the pull actually happens. 4c — "Távoli mentés most" streams real progress. restic was already reporting bytes and percent; the runner seam used CombinedOutput() and discarded them. The manual run now passes --json and scans stdout line-by-line: total bytes, percent, current app. Manual only — the nightly stays silent, pinned by a test that fails if it ever passes --json. The poll now arms unconditionally, closing a race the manual trigger always ran: the redirect rendered before the goroutine wrote LastStatus=running, so the poll never armed and the page sat static during the very run just started. Red-proofed twice. Also closes the golden/controller infra-image drift at the source: infra.Images() derives from the existing pins and --print-infra-images exposes it, so the golden bake can stop carrying its own copy. That copy had already drifted — felhom-samba was never added, so the golden baked 3 of 4, which is why enabling Megosztás pulled at runtime in the first place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
This commit is contained in:
@@ -823,6 +823,11 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data["SharesScratchReady"] = s.backupMgr.SharesScratchReady()
|
||||
data["SharesDisplayName"] = backup.SharesDisplayName
|
||||
}
|
||||
// v0.147.0 (4a): what verification restores have actually left on disk. Previously nothing on any
|
||||
// page listed these, so they accumulated invisibly and the only way to find them was SSH.
|
||||
if s.backupMgr != nil {
|
||||
data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies()
|
||||
}
|
||||
s.executeTemplate(w, r, "backups_restore", data)
|
||||
}
|
||||
|
||||
|
||||
@@ -219,11 +219,13 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Hour)
|
||||
defer cancel()
|
||||
if err := s.backupMgr.RunOffboxBackup(ctx); err != nil {
|
||||
// ...WithProgress: this is the MANUAL trigger, so the page gets live bytes/percent/current app
|
||||
// (4c). The nightly scheduler keeps calling RunOffboxBackup and stays silent.
|
||||
if err := s.backupMgr.RunOffboxBackupWithProgress(ctx); err != nil {
|
||||
s.logger.Printf("[WARN] [web] manual off-box backup failed: %v", err)
|
||||
}
|
||||
}()
|
||||
offboxRedirect(w, r, "A távoli mentés elindult (a futás után az állapot frissül).", false)
|
||||
offboxRedirect(w, r, "A távoli mentés elindult — az állapot itt frissül.", false)
|
||||
}
|
||||
|
||||
// offboxResetHandler is the CLAIMED confirmed orphaned-repo reset (Scenario C): move the old (recovery-
|
||||
@@ -266,6 +268,11 @@ func (s *Server) offboxStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
resp["last_error"] = t.LastError
|
||||
resp["orphaned"] = t.RepoState == "orphaned"
|
||||
}
|
||||
// v0.147.0 (4c): live progress for a MANUAL run. Absent/inactive on the nightly path, so the
|
||||
// page simply keeps its previous „Fut…" behavior there.
|
||||
if s.backupMgr != nil {
|
||||
resp["progress"] = s.backupMgr.OffboxProgressSnapshot()
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
@@ -319,11 +326,57 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] off-box restore %s completed (full=%v, async)", app, full)
|
||||
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" visszaállítva ellenőrző mappába a meghajtón (a meglévő adatok változatlanok).")
|
||||
// NAME THE RESULT (v0.147.0, 4a). The old message said the app had been restored "to a
|
||||
// verification folder on the drive" — which folder, on which drive, was invisible, so the
|
||||
// customer had no way to look at what they had just asked for. Resolve the real path and say
|
||||
// it. Fall back to the vague wording only if the path can no longer be resolved.
|
||||
where := s.backupMgr.OffsiteRestoreScratchPath(app)
|
||||
msg := "A(z) " + app + " visszaállítva ellenőrző mappába a meghajtón (a meglévő adatok változatlanok)."
|
||||
if where != "" {
|
||||
msg = "A(z) " + app + " visszaállítva ellenőrző mappába: " + where + " (a meglévő adatok változatlanok)."
|
||||
}
|
||||
s.backupMgr.EndRestoreOp(true, msg)
|
||||
}()
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A távoli visszaállítás elindult — az állapot itt frissül.", false)
|
||||
}
|
||||
|
||||
// offboxVerifyCopyDeleteHandler removes ONE verification copy (v0.147.0, 4a).
|
||||
//
|
||||
// The only delete this slice adds, so it is deliberately narrow: it names a STACK, never a path — the
|
||||
// customer cannot hand us a path to remove. The Manager resolves that name inside a
|
||||
// `backups/offsite-restore` root it computed itself and refuses anything that lands outside (see
|
||||
// DeleteOffsiteRestoreCopy). The template double-confirms before POSTing.
|
||||
//
|
||||
// It refuses while a backup/restore op is running: the copy being deleted could be the one currently
|
||||
// being written.
|
||||
func (s *Server) offboxVerifyCopyDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backupMgr == nil {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A mentéskezelő nem érhető el.", true)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
stack := strings.TrimSpace(r.FormValue("stack"))
|
||||
if stack == "" {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó ellenőrző másolat.", true)
|
||||
return
|
||||
}
|
||||
if r.FormValue("confirm") != "1" {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A törlés megerősítés nélkül nem hajtható végre.", true)
|
||||
return
|
||||
}
|
||||
if s.backupMgr.IsRunning() {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet fut — a törlés most nem biztonságos.", true)
|
||||
return
|
||||
}
|
||||
if err := s.backupMgr.DeleteOffsiteRestoreCopy(stack); err != nil {
|
||||
s.logger.Printf("[WARN] [web] verification-copy delete %s: %v", stack, err)
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A másolat törlése nem sikerült: "+err.Error(), true)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] verification copy deleted: %s", stack)
|
||||
offboxRedirectTo(w, r, "/backups/restore", "Az ellenőrző másolat törölve. A tényleges adataid változatlanok.", false)
|
||||
}
|
||||
|
||||
// offboxPlaceHandler places a COMPLETED full-restore scratch into the app's live locations via a
|
||||
// missing-only merge (§7.3). Never overwrites existing files. Async on a background context.
|
||||
func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Samba bring-up progress (v0.147.0, feedback slice 4b).
|
||||
//
|
||||
// THE PROBLEM: enabling Megosztás on a fresh box ran `ReconcileSamba()` synchronously inside the
|
||||
// POST handler. On a golden that had not baked felhom-samba, that call is `docker compose up -d`
|
||||
// pulling ~100MB from a private registry — minutes of an apparently-hung form post, then a redirect
|
||||
// with a flash reading „Beállítás mentve." whether or not anything had actually come up. Observed
|
||||
// live, twice. The image is now baked (felhom-agent build-golden.sh, golden >= 0.147.x), but this
|
||||
// card still covers the pre-0.147 goldens and every future image update.
|
||||
//
|
||||
// SHAPE: deliberately the storage-init / netstorage-add one (storage_init_job.go) — detached job,
|
||||
// single-flight slot, deep-copied snapshot, phase strings the template maps to Hungarian. No new
|
||||
// framework; a unified async-job feedback layer is a ROADMAP item, not this slice.
|
||||
//
|
||||
// State is in-memory and lost on restart, exactly like RestoreOpStatus. That is acceptable here: the
|
||||
// terminal truth is „is the container running", which the page re-reads from the stack manager on
|
||||
// every load anyway — the job only explains the WAIT.
|
||||
|
||||
type sambaEnsureJob struct {
|
||||
Phase string `json:"phase"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
const (
|
||||
// sambaPhasePulling — the pinned image is NOT in local Docker storage, so compose will fetch it.
|
||||
// This is the phase worth naming: it is the multi-minute one, and the only honest explanation for
|
||||
// why nothing appears to happen.
|
||||
sambaPhasePulling = "pulling"
|
||||
// sambaPhaseStarting — image already local (baked golden / previously pulled): seconds.
|
||||
sambaPhaseStarting = "starting"
|
||||
// sambaPhaseRunning — terminal success, PROBED (compose up -d exits 0 on a crash-loop, so the
|
||||
// job's success condition is container liveness, never the compose exit code).
|
||||
sambaPhaseRunning = "running"
|
||||
// sambaPhaseNeedsPassword — not a failure: sharing is on but the household password is unset, so
|
||||
// reconcile deliberately deploys nothing. The card must say so instead of spinning forever.
|
||||
sambaPhaseNeedsPassword = "needs_password"
|
||||
sambaPhaseFailed = "failed"
|
||||
sambaPhaseIdle = "idle"
|
||||
)
|
||||
|
||||
// A pull that has not finished in 15 minutes is not slow, it is broken (the registry is unreachable
|
||||
// or the disk is full) — end the job so the card can say so rather than spin indefinitely.
|
||||
const sambaEnsureDeadline = 15 * time.Minute
|
||||
|
||||
type sambaEnsureState struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
cur *sambaEnsureJob
|
||||
}
|
||||
|
||||
func (s *sambaEnsureState) acquire(job *sambaEnsureJob) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.running {
|
||||
return false
|
||||
}
|
||||
s.running = true
|
||||
cp := *job
|
||||
s.cur = &cp
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *sambaEnsureState) release() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *sambaEnsureState) set(job *sambaEnsureJob) {
|
||||
s.mu.Lock()
|
||||
cp := *job
|
||||
s.cur = &cp
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// snapshot returns a copy of the last / in-flight job (nil = never ran this process).
|
||||
func (s *sambaEnsureState) snapshot() *sambaEnsureJob {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cur == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *s.cur
|
||||
return &cp
|
||||
}
|
||||
|
||||
// startSambaEnsure claims the single-flight slot and launches the detached reconcile. false = one is
|
||||
// already in flight (a double-submit must not start a second compose up on the same stack dir).
|
||||
//
|
||||
// The opening phase is decided BEFORE the work starts, by asking whether the image is already local
|
||||
// — afterwards the answer is always yes and the card could never truthfully say „letöltés".
|
||||
func (s *Server) startSambaEnsure() bool {
|
||||
now := time.Now().UTC()
|
||||
phase := sambaPhaseStarting
|
||||
if s.stackMgr != nil && !s.stackMgr.SambaImagePresent() {
|
||||
phase = sambaPhasePulling
|
||||
}
|
||||
job := &sambaEnsureJob{Phase: phase, StartedAt: now, UpdatedAt: now}
|
||||
if !s.sambaEnsure.acquire(job) {
|
||||
return false
|
||||
}
|
||||
go s.runSambaEnsureJob(job)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) runSambaEnsureJob(job *sambaEnsureJob) {
|
||||
defer s.sambaEnsure.release()
|
||||
|
||||
advance := func(phase, errMsg string) {
|
||||
job.Phase = phase
|
||||
job.Error = errMsg
|
||||
job.UpdatedAt = time.Now().UTC()
|
||||
s.sambaEnsure.set(job)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.stackMgr.ReconcileSamba() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [sharing] samba reconcile failed after %s: %v", time.Since(job.StartedAt).Round(time.Second), err)
|
||||
advance(sambaPhaseFailed, err.Error())
|
||||
return
|
||||
}
|
||||
case <-time.After(sambaEnsureDeadline):
|
||||
// The reconcile goroutine is left running — compose owns its own lifecycle and killing it
|
||||
// mid-pull would leave a partial layer set. We stop REPORTING on it, which is the honest
|
||||
// thing the customer needs; a later page load re-probes liveness for the real answer.
|
||||
s.logger.Printf("[ERROR] [sharing] samba reconcile still running after %s — giving up on the progress card", sambaEnsureDeadline)
|
||||
advance(sambaPhaseFailed, "a megosztási szolgáltatás előkészítése túl sokáig tartott")
|
||||
return
|
||||
}
|
||||
|
||||
// Reconcile returned nil — but nil ALSO covers "deliberately did nothing". Distinguish the two,
|
||||
// because a card that says „fut" while nothing is deployed is the same silence in a new costume.
|
||||
if s.settings != nil && !s.settings.GetSMBSettings().UserSet {
|
||||
advance(sambaPhaseNeedsPassword, "")
|
||||
return
|
||||
}
|
||||
if !s.stackMgr.SambaRunning() {
|
||||
s.logger.Printf("[ERROR] [sharing] samba reconcile reported success but the container is not running")
|
||||
advance(sambaPhaseFailed, "a megosztási szolgáltatás nem indult el")
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [sharing] samba ready after %s", time.Since(job.StartedAt).Round(time.Second))
|
||||
advance(sambaPhaseRunning, "")
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.147.0 slice 4b — the Megosztás bring-up progress card.
|
||||
//
|
||||
// The card's whole value is that it distinguishes states the old synchronous handler collapsed into
|
||||
// one silent form post. So the tests are about the DISTINCTIONS:
|
||||
// - "pulling" vs "starting" — decided by local image presence, and decided BEFORE the work starts
|
||||
// (afterwards the image is always present and the card could never truthfully say „letöltés").
|
||||
// - reconcile-returned-nil is NOT the same as running: it also covers "deliberately deployed
|
||||
// nothing because there is no household password yet".
|
||||
// - compose up -d exits 0 on a crash-loop, so success must be PROBED, never inferred.
|
||||
//
|
||||
// SCOPE, STATED HONESTLY: these cover the single-flight slot, snapshot isolation and the phase
|
||||
// vocabulary. They do NOT drive runSambaEnsureJob end-to-end — that needs a real *stacks.Manager
|
||||
// (the Server field is the concrete type, not an interface), and introducing an interface purely for
|
||||
// this card was more churn than the slice warranted. The pulling-vs-starting decision and the
|
||||
// probed-liveness terminal state are therefore covered by the LIVE validation on the demo box, not
|
||||
// by a unit test. Recorded here rather than left as an implied gap; the ROADMAP's unified
|
||||
// async-job feedback item is where that seam belongs.
|
||||
|
||||
func TestSambaEnsureSingleFlight(t *testing.T) {
|
||||
var st sambaEnsureState
|
||||
job := &sambaEnsureJob{Phase: sambaPhaseStarting}
|
||||
if !st.acquire(job) {
|
||||
t.Fatal("first acquire refused")
|
||||
}
|
||||
// A double-submit must not start a second `compose up -d` against the same stack dir.
|
||||
if st.acquire(&sambaEnsureJob{Phase: sambaPhaseStarting}) {
|
||||
t.Error("second acquire succeeded while a job was in flight")
|
||||
}
|
||||
st.release()
|
||||
if !st.acquire(&sambaEnsureJob{Phase: sambaPhaseStarting}) {
|
||||
t.Error("acquire refused after release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSambaEnsureSnapshotIsACopy(t *testing.T) {
|
||||
var st sambaEnsureState
|
||||
job := &sambaEnsureJob{Phase: sambaPhasePulling}
|
||||
st.acquire(job)
|
||||
|
||||
snap := st.snapshot()
|
||||
snap.Phase = "mutated-by-caller"
|
||||
if again := st.snapshot(); again.Phase != sambaPhasePulling {
|
||||
t.Errorf("a caller mutated the shared job through its snapshot: %q", again.Phase)
|
||||
}
|
||||
// And the live job must not leak into an already-taken snapshot either.
|
||||
snap2 := st.snapshot()
|
||||
job.Phase = sambaPhaseRunning
|
||||
st.set(job)
|
||||
if snap2.Phase != sambaPhasePulling {
|
||||
t.Errorf("an earlier snapshot changed under the caller: %q", snap2.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSambaEnsureStateStartsNil(t *testing.T) {
|
||||
var st sambaEnsureState
|
||||
// nil = never ran this process. The status handler maps that to `idle` and lets live container
|
||||
// state win, so a page loaded after a restart still tells the truth.
|
||||
if j := st.snapshot(); j != nil {
|
||||
t.Errorf("fresh state reported a job: %+v", j)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSambaPhaseConstantsAreDistinct guards the template contract: sharing.html maps these exact
|
||||
// strings, and a duplicate would silently render the wrong Hungarian sentence.
|
||||
func TestSambaPhaseConstantsAreDistinct(t *testing.T) {
|
||||
all := []string{
|
||||
sambaPhasePulling, sambaPhaseStarting, sambaPhaseRunning,
|
||||
sambaPhaseNeedsPassword, sambaPhaseFailed, sambaPhaseIdle,
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, p := range all {
|
||||
if p == "" {
|
||||
t.Error("a phase constant is empty — the template would fall through to 'no card'")
|
||||
}
|
||||
if seen[p] {
|
||||
t.Errorf("duplicate phase constant %q", p)
|
||||
}
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,8 @@ type Server struct {
|
||||
// netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec).
|
||||
netAdd netAddState
|
||||
storageInit storageInitState
|
||||
// sambaEnsure is the Megosztás bring-up progress slot (v0.147.0, 4b) — same single-flight shape.
|
||||
sambaEnsure sambaEnsureState
|
||||
netAgentFn func() (netAgent, error)
|
||||
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
|
||||
fabUpload uploadState
|
||||
@@ -359,6 +361,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.sharingPageHandler(w, r)
|
||||
case path == "/sharing/enable" && r.Method == http.MethodPost:
|
||||
s.sharingEnableHandler(w, r)
|
||||
case path == "/sharing/status" && r.Method == http.MethodGet:
|
||||
s.sharingStatusHandler(w, r)
|
||||
case path == "/sharing/password" && r.Method == http.MethodPost:
|
||||
s.sharingPasswordHandler(w, r)
|
||||
case path == "/sharing/shares" && r.Method == http.MethodPost:
|
||||
@@ -418,6 +422,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.offboxRestoreHandler(w, r)
|
||||
case path == "/backup/offbox/place" && r.Method == http.MethodPost:
|
||||
s.offboxPlaceHandler(w, r)
|
||||
// v0.147.0 (4a): remove ONE verification copy. The only delete path this slice adds — see
|
||||
// DeleteOffsiteRestoreCopy for the prefix guard.
|
||||
case path == "/backup/offbox/verify-copy/delete" && r.Method == http.MethodPost:
|
||||
s.offboxVerifyCopyDeleteHandler(w, r)
|
||||
// R-7b: „Megosztások" restore — a sibling of the per-app pair above.
|
||||
case path == "/backup/shares/restore" && r.Method == http.MethodPost:
|
||||
s.sharesRestoreHandler(w, r)
|
||||
|
||||
@@ -221,10 +221,33 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.")
|
||||
return
|
||||
}
|
||||
if err := s.stackMgr.ReconcileSamba(); err != nil {
|
||||
s.logger.Printf("[WARN] [sharing] reconcile failed: %v", err)
|
||||
// v0.147.0 (4b): the bring-up runs DETACHED and the page polls it. Synchronously it was a form
|
||||
// post that hung for minutes on a first-enable image pull and then flashed „Beállítás mentve."
|
||||
// regardless of whether anything actually came up.
|
||||
if !s.startSambaEnsure() {
|
||||
sharingRedirect(w, r, "A megosztási szolgáltatás előkészítése már folyamatban van.")
|
||||
return
|
||||
}
|
||||
sharingRedirect(w, r, "Beállítás mentve.")
|
||||
sharingRedirect(w, r, "Beállítás mentve. A megosztási szolgáltatás előkészítése folyamatban…")
|
||||
}
|
||||
|
||||
// sharingStatusHandler is the 4b poll target (GET /sharing/status). Reports the ensure job's phase
|
||||
// plus the live container state, so a page loaded AFTER the job finished (or after a restart, when
|
||||
// the in-memory job is gone) still shows the truth.
|
||||
func (s *Server) sharingStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
phase := sambaPhaseIdle
|
||||
errMsg := ""
|
||||
if job := s.sambaEnsure.snapshot(); job != nil {
|
||||
phase, errMsg = job.Phase, job.Error
|
||||
}
|
||||
running := s.stackMgr != nil && s.stackMgr.SambaRunning()
|
||||
// A stale `idle`/`running` job must never contradict reality: liveness wins on a fresh page.
|
||||
if phase == sambaPhaseIdle && running {
|
||||
phase = sambaPhaseRunning
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
|
||||
"phase": phase, "error": errMsg, "running": running,
|
||||
})
|
||||
}
|
||||
|
||||
// sharingPasswordHandler sets the household SMB password (POST /sharing/password).
|
||||
@@ -247,10 +270,14 @@ func (s *Server) sharingPasswordHandler(w http.ResponseWriter, r *http.Request)
|
||||
sharingRedirect(w, r, "A jelszó beállítása nem sikerült.")
|
||||
return
|
||||
}
|
||||
if err := s.stackMgr.ReconcileSamba(); err != nil {
|
||||
s.logger.Printf("[WARN] [sharing] reconcile after password failed: %v", err)
|
||||
// Setting the password is the moment the stack ACTUALLY first comes up: with UserSet false,
|
||||
// reconcile deliberately deploys nothing, so on a fresh box this — not the enable toggle — is
|
||||
// where the image pull happens. Same detached job, same card.
|
||||
if !s.startSambaEnsure() {
|
||||
sharingRedirect(w, r, "Megosztási jelszó beállítva. Az előkészítés már folyamatban van.")
|
||||
return
|
||||
}
|
||||
sharingRedirect(w, r, "Megosztási jelszó beállítva.")
|
||||
sharingRedirect(w, r, "Megosztási jelszó beállítva. A megosztási szolgáltatás előkészítése folyamatban…")
|
||||
}
|
||||
|
||||
// sharingShareCreateHandler creates a share (POST /sharing/shares). Either a NEW folder under
|
||||
|
||||
@@ -126,6 +126,15 @@
|
||||
<button type="submit" class="btn btn-sm btn-primary">Távoli mentés most</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- v0.147.0 (4c): live progress for a MANUAL run — total bytes, percent and which app is being
|
||||
pushed, parsed from restic's --json status stream. Hidden until a run reports progress; the
|
||||
nightly run never populates it. -->
|
||||
<div id="offbox-progress" class="alert alert-info" style="display:none;margin-top:.75rem">
|
||||
<span id="offbox-progress-text"></span>
|
||||
<div class="progress-bar-task" style="margin-top:.5rem">
|
||||
<div id="offbox-progress-bar" class="progress-fill" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h4 style="margin-top:1.25rem">Mely alkalmazások mentődnek a távoli tárolóra?</h4>
|
||||
{{if and .OffboxApps (eq .Offbox.EscrowState "escrowed") (eq .OffboxToggledCount 0)}}
|
||||
<p class="form-hint">Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.</p>
|
||||
@@ -172,19 +181,55 @@
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
/* Part C (v0.142.0): while a remote backup is in flight the status card shows "Fut…" — poll the run
|
||||
status and reload once it reaches a terminal state, so the customer sees Rendben/Hiba + fresh
|
||||
numbers WITHOUT a manual reload. Polls only when a run is running; stops (page reload) at terminal. */
|
||||
/* Part C (v0.142.0) + 4c (v0.147.0): poll the run status, render live progress for a manual run, and
|
||||
reload once it reaches a terminal state so the customer sees Rendben/Hiba + fresh numbers WITHOUT a
|
||||
manual reload.
|
||||
|
||||
v0.147.0 changed WHEN polling starts. It used to begin only if the page already rendered "Fut…",
|
||||
which loses a race the manual trigger always runs: the POST redirects and this page renders before
|
||||
the detached goroutine has written LastStatus=running, so the poll never armed and the customer
|
||||
watched a static page during the very run they had just started. Now it always arms and gives up
|
||||
after GRACE quiet ticks if nothing is (or ever was) in flight. */
|
||||
(function(){
|
||||
var v=document.getElementById('offbox-status-value');
|
||||
if(!v || v.textContent.indexOf('Fut')<0) return; // not running → nothing to poll
|
||||
var timer=setInterval(function(){
|
||||
var v = document.getElementById('offbox-status-value');
|
||||
var box = document.getElementById('offbox-progress');
|
||||
var text = document.getElementById('offbox-progress-text');
|
||||
var bar = document.getElementById('offbox-progress-bar');
|
||||
var GRACE = 5; // ~15s of quiet before concluding nothing is running
|
||||
var quiet = 0;
|
||||
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
|
||||
|
||||
var timer = setInterval(function(){
|
||||
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d && d.status==='running') return; // still running → keep polling
|
||||
clearInterval(timer);
|
||||
location.reload(); // terminal → re-render (fresh numbers, warnings, or the orphan card)
|
||||
if(!d) return;
|
||||
var p = d.progress || {};
|
||||
var running = (d.status === 'running') || p.active;
|
||||
|
||||
if(running){
|
||||
sawRunning = true; quiet = 0;
|
||||
if(p.active && box){
|
||||
/* Only claim a percentage once restic has told us a total — before the scan finishes,
|
||||
percent is 0 of 0, and a bar pinned at 0% reads as "stuck" rather than "measuring". */
|
||||
var label = p.current_app ? ('Mentés: ' + p.current_app) : 'Mentés folyamatban';
|
||||
if(p.total_bytes > 0){
|
||||
label += ' — ' + Math.round(p.percent) + '% (' + p.done_human + ' / ' + p.total_human + ')';
|
||||
bar.style.width = Math.max(0, Math.min(100, p.percent)) + '%';
|
||||
} else {
|
||||
label += ' — a mentendő adatok felmérése…';
|
||||
bar.style.width = '0%';
|
||||
}
|
||||
text.textContent = label;
|
||||
box.style.display = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* Not running. Terminal only if we ever saw it running — otherwise this is the pre-start
|
||||
race (or a page nobody triggered anything from). */
|
||||
if(sawRunning){ clearInterval(timer); location.reload(); return; }
|
||||
if(++quiet >= GRACE){ clearInterval(timer); }
|
||||
})
|
||||
.catch(function(){ /* transient — keep polling */ });
|
||||
}, 3000);
|
||||
|
||||
@@ -121,6 +121,32 @@
|
||||
{{template "app_list_row_end"}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- v0.147.0 (4a): what the verification restores actually LEFT on disk. Until now the result of
|
||||
an ellenőrző visszaállítás was invisible — the flash said "a verification folder on the
|
||||
drive" without naming it, and nothing listed what had accumulated. Full path, size and date,
|
||||
so the copy can be found, opened, and cleaned up. -->
|
||||
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
|
||||
<h3 style="margin-bottom:.5rem">Meglévő ellenőrző másolatok</h3>
|
||||
{{if .OffsiteRestoreCopies}}
|
||||
<p class="form-hint" style="margin-bottom:.75rem">Ezek a visszaállított másolatok helyet foglalnak a meghajtón. A tényleges adataidat nem érintik, bármikor törölhetők.</p>
|
||||
<div class="app-row-list">
|
||||
{{range .OffsiteRestoreCopies}}
|
||||
{{template "app_list_row" dict "Slug" .Stack "Name" .Stack "Secondary" (printf "%s · %s · %s" .SizeHuman (.Created.Format "2006. 01. 02. 15:04") .Path)}}
|
||||
<form method="POST" action="/backup/offbox/verify-copy/delete" style="display:inline">
|
||||
{{$.CSRFField}}
|
||||
<input type="hidden" name="stack" value="{{.Stack}}">
|
||||
<input type="hidden" name="confirm" value="1">
|
||||
<button type="button" class="btn btn-xs btn-danger-outline"
|
||||
data-copy-path="{{.Path}}"
|
||||
onclick="confirmDeleteVerifyCopy(this)">Másolat törlése</button>
|
||||
</form>
|
||||
{{template "app_list_row_end"}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="form-hint">Nincs ellenőrző másolat a meghajtón.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -178,6 +204,19 @@ function fabDownload(btn){
|
||||
})
|
||||
.catch(function(){ fabSetStatus('Hiba: a becslés nem érhető el.'); });
|
||||
}
|
||||
// v0.147.0 (4a): the ONLY delete this page offers. Two inline acknowledgements — the house
|
||||
// double-confirm idiom (deploy.html's stale-data delete), never native confirm(), which is an
|
||||
// OS-modal that blocks browser automation (F-11). The first step names the exact path so the
|
||||
// customer is agreeing to a specific directory, not to the word "delete".
|
||||
function confirmDeleteVerifyCopy(btn){
|
||||
var path = btn.getAttribute('data-copy-path') || '';
|
||||
felhomConfirm(btn, 'Biztosan törlöd ezt az ellenőrző másolatot? ' + path + ' — a tényleges adataid változatlanok maradnak.', function(){
|
||||
felhomConfirm(btn, 'UTOLSÓ MEGERŐSÍTÉS: a másolat véglegesen törlődik.', function(){
|
||||
var f = btn.closest('form');
|
||||
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
function fabStart(stack, next){
|
||||
fetch('/api/export/download/start', {method:'POST', headers:Object.assign({'Content-Type':'application/json'}, csrfHeaders()), body: JSON.stringify({stack_name: stack, password: fabPassword()})})
|
||||
.then(function(r){ return r.json(); })
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- v0.147.0 (4b): bring-up progress. Hidden until the poll reports a non-idle phase, so a page
|
||||
with nothing in flight looks exactly as it did before. This is what makes the first-enable
|
||||
wait survivable on a golden that has not baked felhom-samba: the pull is named, not silent. -->
|
||||
<div id="samba-progress" class="alert alert-info" style="display:none">
|
||||
<span id="samba-progress-text"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<span>Állapot:</span>
|
||||
{{if .SMBRunning}}
|
||||
@@ -282,6 +289,54 @@ function shareBrowseLoad(p){
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* v0.147.0 (4b) — Megosztás bring-up poll. Same shape as the storage-init status poll
|
||||
(storage_init.html): 1.5s tick, phase string mapped to Hungarian, terminal states stop the timer.
|
||||
The card only ever appears while something is genuinely in flight. */
|
||||
(function(){
|
||||
var box = document.getElementById('samba-progress');
|
||||
var txt = document.getElementById('samba-progress-text');
|
||||
if (!box || !txt) return;
|
||||
var PHASES = {
|
||||
pulling: 'A megosztási szolgáltatás előkészítése… (képfájl letöltése — ez több percig tarthat)',
|
||||
starting: 'A megosztási szolgáltatás indítása…',
|
||||
needs_password: 'A megosztás be van kapcsolva, de még nincs megosztási jelszó — add meg alább.'
|
||||
};
|
||||
var timer = null;
|
||||
function stop(){ if (timer) { clearInterval(timer); timer = null; } }
|
||||
function show(cls, text){
|
||||
box.className = 'alert ' + cls;
|
||||
txt.textContent = text;
|
||||
box.style.display = '';
|
||||
}
|
||||
function tick(){
|
||||
fetch('/sharing/status', {credentials:'same-origin'})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(j){
|
||||
if (!j || !j.ok || !j.data) return; // transient — keep polling
|
||||
var ph = j.data.phase;
|
||||
if (PHASES[ph]) { show('alert-info', PHASES[ph]); return; }
|
||||
if (ph === 'running') {
|
||||
stop();
|
||||
show('alert-success', 'A megosztási szolgáltatás fut.');
|
||||
/* Repaint the „Állapot" badge, which was rendered server-side as „áll". */
|
||||
setTimeout(function(){ location.reload(); }, 1200);
|
||||
return;
|
||||
}
|
||||
if (ph === 'failed') {
|
||||
stop();
|
||||
show('alert-error', 'A megosztási szolgáltatás nem indult el' + (j.data.error ? ': ' + j.data.error : '.'));
|
||||
return;
|
||||
}
|
||||
/* idle and nothing running: nothing to report. */
|
||||
stop();
|
||||
box.style.display = 'none';
|
||||
})
|
||||
.catch(function(){ /* transient — keep polling */ });
|
||||
}
|
||||
tick();
|
||||
timer = setInterval(tick, 1500);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
|
||||
Reference in New Issue
Block a user