v0.311.0: after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007)
gates / gates (push) Successful in 52s
gates / gates (push) Successful in 52s
New template block after_unclean_boot (decision 236): once per controller start that followed an UNCLEAN host boot (crashboot's own verdict, after the 15-minute crash grace; a normal or unknown boot never runs it), each app with the block rebuilds ONLY its zero-byte files (scan: regular files, no symlinks, its own filesystem; ids appended to the app's own command in batches; one retry, then reported) and its own lost-file check is read. The household sees lost files (name, date taken) on the app page until dismissed; the operator gets app_repaired_after_crash with counts only. Skips, with the reason, an unproven image, an unknown key, an app not running. MinAgent 0.155.0 unchanged. Tests red-proofed (14 guards). One new parity fixture (the new card only). Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
@@ -1,3 +1,32 @@
|
||||
## v0.311.0 — after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007) (2026-10-14)
|
||||
|
||||
**MinAgent: 0.155.0** (unchanged — the crash fact is the existing `GET /host/crash-guard`). **Hub:** v0.150.0 registers
|
||||
`app_repaired_after_crash` (allow-listed + operator-only); an older hub answers 400 and the operator gets no counts —
|
||||
the repair and the household's note do not depend on it. **Catalog:** Immich's `after_unclean_boot` block. Design: `09`
|
||||
§3 decision 236; evidence felhom.eu `documentation/audits/immich-repair-2026-10-14/`.
|
||||
|
||||
- **New template block `after_unclean_boot`** (`internal/stacks/after_unclean_boot.go`): where the zero-byte files are
|
||||
(`under`, only `${HDD_PATH}` filled in), how a name maps to the app's id (one capture group), the repair command (ids
|
||||
appended, plain id text only, in batches) and the app's own lost-file check (`LOST {json}` lines). The box finds
|
||||
zero-byte REGULAR files only (no symlinks, its own filesystem), runs the repair, re-scans, retries a still-empty file
|
||||
ONCE, then reports it. Skips, with the reason, an image the template has not proven, a key it does not know, an app
|
||||
that is not running within 10 min. It never deletes or rewrites a file, never stops the app.
|
||||
- **When** (`internal/aftercrash`): once per controller start that followed an UNCLEAN host boot — `crashboot`'s own
|
||||
verdict (new `Grace.Decided`), after the same 15-minute crash grace; a normal or unknown boot never runs it. Runs off
|
||||
the scheduler (a goroutine; never twice).
|
||||
- **The household:** the Immich app page shows „Elveszett fotók egy váratlan leállás miatt" / "Photos lost in an
|
||||
unexpected stop" — each lost file's name and the date it was taken, the boot time, „upload them again from the phone",
|
||||
and a dismiss button (`POST /apps/<slug>/lost-files/dismiss`). Nothing when nothing is lost. The list lives only in
|
||||
`app.yaml` on the box.
|
||||
- **The operator:** `app_repaired_after_crash` — counts only (found / repaired / still broken / lost, the boot, the
|
||||
duration, a skip reason). warning when anything is lost, still broken, failed or skipped; info when all repaired;
|
||||
nothing when nothing was found. File names never reach a log line or an event (tests assert both).
|
||||
- Tests: `internal/stacks/after_unclean_boot_test.go`, `internal/aftercrash/aftercrash_test.go`,
|
||||
`internal/crashboot` (`TestDecided_*`), `internal/notify/app_repaired_after_crash_test.go`,
|
||||
`internal/web/lost_files_note_test.go`, one new parity fixture (`app_info_lost_files`, the new card only).
|
||||
Red-proofed: 14 guards, each removed in turn convicted its test (the symlink rule only with the regular-file check
|
||||
removed too — that check is what holds; both stay).
|
||||
|
||||
## v0.310.0 — „A gép kikapcsolása": the household can turn the whole machine off (R-998); honest restart text (R-999) (2026-10-13)
|
||||
|
||||
**MinAgent: 0.155.0** — the new route calls the agent's `POST /host/poweroff`. An older agent: the button is hidden and
|
||||
|
||||
@@ -3297,6 +3297,16 @@ alarms for 14 days (hub v0.149.0). Design: felhom.eu `documentation/architecture
|
||||
**„A Felhom újraindítása"** (was „Kiszolgáló újraindítása", R-999) restarts the guest — the dashboard and the apps —
|
||||
not the machine; its text now says so. `/api/server/reboot` is POST only (R-983, one of its seven).
|
||||
|
||||
### 8b. After a power cut — an app repairs its empty files (v0.311.0, R-1007)
|
||||
|
||||
A power cut while an app writes can leave files ZERO BYTES. A template may carry `after_unclean_boot` (catalog README
|
||||
„After an unclean boot"): where such files are, how the app rebuilds THOSE only, and its own lost-file check. Once per
|
||||
controller start that followed an UNCLEAN host boot (the crash guard's fact via the agent; unknown = normal), after
|
||||
the 15-minute crash grace, the box scans, asks the app to repair, re-scans, retries once, and reads the lost list.
|
||||
The household sees lost files (name, date taken) on the app page until dismissed; the operator gets
|
||||
`app_repaired_after_crash` with counts only. Immich is the first user (`09` §3 decision 236: Immich's own job queue —
|
||||
no key into the household's account). Code: `internal/stacks/after_unclean_boot.go`, `internal/aftercrash/`.
|
||||
|
||||
### 9. Central Hub Reporting
|
||||
|
||||
#### Operator actions (`internal/report/opactions.go`, `09` §3 decision 185)
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/aftercrash"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
@@ -963,6 +964,32 @@ func main() {
|
||||
}
|
||||
}
|
||||
bootGrace := crashboot.New(startTime, crashProbe, logger)
|
||||
// R-1007 (`09` §3 decision 236): ONCE per start that followed an unclean host boot — the same verdict, after the same
|
||||
// CrashGrace — each app with an after_unclean_boot block rebuilds the files the power cut left empty and its own
|
||||
// lost-file check is read. A normal or unknown boot never runs it. The operator gets counts only
|
||||
// (app_repaired_after_crash); the household's note lives on the app page. Pinned by internal/aftercrash.
|
||||
aubRunner := aftercrash.New(bootGrace, stackMgr.AfterUncleanBootApps, stackMgr.RunAfterUncleanBoot,
|
||||
func(name string, bootAt time.Time, res stacks.AfterUncleanBootResult) {
|
||||
disp := name
|
||||
if st, ok := stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" {
|
||||
disp = st.Meta.DisplayName
|
||||
}
|
||||
d := notify.AppRepairDetails{App: disp, StackName: name, Found: res.Found, Repaired: res.Repaired,
|
||||
StillBroken: res.StillBroken, Lost: res.Lost, LostChecked: res.LostChecked,
|
||||
DurationS: int64(res.Duration.Round(time.Second) / time.Second), Skipped: res.Skipped, Problem: res.Problem}
|
||||
if !bootAt.IsZero() {
|
||||
d.BootAt = bootAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
notifier.NotifyAppRepairedAfterCrash(d)
|
||||
}, logger)
|
||||
var aubBusy sync.Mutex
|
||||
sched.Every("after-unclean-boot", 30*time.Second, func(ctx context.Context) error {
|
||||
if aubRunner.Done() || !aubBusy.TryLock() {
|
||||
return nil
|
||||
}
|
||||
go func() { defer aubBusy.Unlock(); aubRunner.Tick(time.Now()) }() // a run can take minutes; never block the scheduler
|
||||
return nil
|
||||
})
|
||||
sched.Every("deadapp-check", 30*time.Second, func(ctx context.Context) error {
|
||||
if bootGrace.Within(time.Now()) {
|
||||
return nil // still inside the startup settle window (90 s, or ~15 min after a crash boot)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Package aftercrash runs the apps' after_unclean_boot blocks ONCE per controller start that followed an unclean
|
||||
// host boot (R-1007, `09` §3 decision 236).
|
||||
//
|
||||
// The fact is crashboot's (the host crash guard, read through the agent): the same verdict that holds the app mails
|
||||
// for CrashGrace. Unknown is a normal boot — a repair never runs on a guess. A normal boot never runs it. After a crash
|
||||
// boot it waits CrashGrace from the controller's start (the apps come up slowly after a crash), then runs each app's
|
||||
// block one after the other and reports each app's counts. It never stops an app; a failed run is reported, not
|
||||
// retried. Pinned by aftercrash_test.go.
|
||||
package aftercrash
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// Verdict is crashboot.Grace's Decided (a seam for tests).
|
||||
type Verdict func(now time.Time) (resolved, crash bool, bootAt time.Time)
|
||||
|
||||
// Runner does the one run. Every func field is required; Logger may be nil.
|
||||
type Runner struct {
|
||||
Start time.Time
|
||||
Verdict Verdict
|
||||
Apps func() []string
|
||||
Run func(name string, bootAt time.Time) stacks.AfterUncleanBootResult
|
||||
Report func(name string, bootAt time.Time, res stacks.AfterUncleanBootResult)
|
||||
Logger *log.Logger
|
||||
// Wait is how long after Start a crash-boot run waits (default crashboot.CrashGrace).
|
||||
Wait time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
done bool
|
||||
}
|
||||
|
||||
// New builds the runner on the controller's crash-boot grace.
|
||||
func New(g *crashboot.Grace, apps func() []string,
|
||||
run func(string, time.Time) stacks.AfterUncleanBootResult,
|
||||
report func(string, time.Time, stacks.AfterUncleanBootResult), logger *log.Logger) *Runner {
|
||||
return &Runner{Start: g.Start(), Verdict: g.Decided, Apps: apps, Run: run, Report: report, Logger: logger}
|
||||
}
|
||||
|
||||
// Done reports whether this start's decision has been taken (ran, or decided not to).
|
||||
func (r *Runner) Done() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.done
|
||||
}
|
||||
|
||||
// Tick is called periodically (the scheduler). It returns true once the run has happened or been ruled out.
|
||||
func (r *Runner) Tick(now time.Time) bool {
|
||||
r.mu.Lock()
|
||||
if r.done {
|
||||
r.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
resolved, crash, bootAt := r.Verdict(now)
|
||||
if !resolved {
|
||||
r.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
if !crash {
|
||||
r.done = true
|
||||
r.mu.Unlock()
|
||||
r.logf("[INFO] [aftercrash] a normal (or unknown) boot — no app repair after an unclean boot (R-1007)")
|
||||
return true
|
||||
}
|
||||
wait := r.Wait
|
||||
if wait <= 0 {
|
||||
wait = crashboot.CrashGrace
|
||||
}
|
||||
if now.Sub(r.Start) < wait {
|
||||
r.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
r.done = true // set BEFORE the run: a slow or failing run is never started twice
|
||||
r.mu.Unlock()
|
||||
|
||||
apps := r.Apps()
|
||||
r.logf("[INFO] [aftercrash] the host's boot at %s followed an unclean stop — %d app(s) with an after_unclean_boot block (R-1007)",
|
||||
bootAt.UTC().Format(time.RFC3339), len(apps))
|
||||
for _, name := range apps {
|
||||
res := r.Run(name, bootAt)
|
||||
if res.Skipped != "" {
|
||||
r.logf("[WARN] [aftercrash] %s: skipped — %s", name, res.Skipped)
|
||||
} else {
|
||||
r.logf("[INFO] [aftercrash] %s: found %d empty file(s), repaired %d, still broken %d, lost %d (checked=%t) in %s%s",
|
||||
name, res.Found, res.Repaired, res.StillBroken, res.Lost, res.LostChecked, res.Duration.Round(time.Millisecond), problem(res.Problem))
|
||||
}
|
||||
r.Report(name, bootAt, res)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func problem(p string) string {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
return " — problem: " + p
|
||||
}
|
||||
|
||||
func (r *Runner) logf(format string, args ...any) {
|
||||
if r.Logger != nil {
|
||||
r.Logger.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package aftercrash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-1007 (decision 236): the repair runs ONLY after an unclean host boot that THIS start followed, only after the
|
||||
// crash grace, and only once. A normal boot and an unknown fact never run it. The verdict is crashboot's own (the
|
||||
// real Grace with a fake probe), so these tests assert the consequence: was the app's block run?
|
||||
|
||||
var start = time.Date(2026, 10, 14, 12, 8, 0, 0, time.UTC)
|
||||
|
||||
func probe(f crashboot.Fact, err error) crashboot.Probe {
|
||||
return func(context.Context) (crashboot.Fact, error) { return f, err }
|
||||
}
|
||||
|
||||
func runner(p crashboot.Probe) (*Runner, *[]string) {
|
||||
var ran []string
|
||||
g := crashboot.New(start, p, nil)
|
||||
r := New(g, func() []string { return []string{"immich"} },
|
||||
func(name string, _ time.Time) stacks.AfterUncleanBootResult {
|
||||
ran = append(ran, name)
|
||||
return stacks.AfterUncleanBootResult{}
|
||||
},
|
||||
func(string, time.Time, stacks.AfterUncleanBootResult) {}, nil)
|
||||
return r, &ran
|
||||
}
|
||||
|
||||
// RED-PROOF: drop the `now.Sub(r.Start) < wait` check → the run happens at +1 min → fails.
|
||||
// RED-PROOF: drop `r.done = true` before the run → a second tick runs it again → fails.
|
||||
func TestAfterCrash_RunsOnceAfterTheGraceOnAnUncleanBootThisStartFollowed(t *testing.T) {
|
||||
r, ran := runner(probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-40 * time.Second)}, nil))
|
||||
for _, at := range []time.Duration{0, time.Minute, 14 * time.Minute} {
|
||||
r.Tick(start.Add(at))
|
||||
}
|
||||
if len(*ran) != 0 {
|
||||
t.Fatalf("ran inside the crash grace: %v", *ran)
|
||||
}
|
||||
r.Tick(start.Add(crashboot.CrashGrace + time.Second))
|
||||
r.Tick(start.Add(crashboot.CrashGrace + time.Minute))
|
||||
if len(*ran) != 1 {
|
||||
t.Fatalf("ran %d time(s), want exactly once after the grace", len(*ran))
|
||||
}
|
||||
}
|
||||
|
||||
// RED-PROOF: in Tick, treat `!crash` like a crash (drop the early return) → a normal boot runs the repair → fails.
|
||||
func TestAfterCrash_NeverOnANormalAnUnknownOrAnOldUncleanBoot(t *testing.T) {
|
||||
cases := map[string]crashboot.Probe{
|
||||
"normal boot": probe(crashboot.Fact{Known: true, Unclean: false, BootAt: start.Add(-time.Minute)}, nil),
|
||||
"unknown (no crash guard)": probe(crashboot.Fact{}, nil),
|
||||
"unknown (agent unreachable)": probe(crashboot.Fact{}, context.DeadlineExceeded),
|
||||
"no agent at all": nil,
|
||||
"an unclean boot days ago": probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-72 * time.Hour)}, nil),
|
||||
"an unclean boot with no time": probe(crashboot.Fact{Known: true, Unclean: true}, nil),
|
||||
}
|
||||
for name, p := range cases {
|
||||
r, ran := runner(p)
|
||||
for at := time.Duration(0); at <= 2*crashboot.CrashGrace; at += 30 * time.Second {
|
||||
r.Tick(start.Add(at))
|
||||
}
|
||||
if len(*ran) != 0 {
|
||||
t.Errorf("%s: the repair ran", name)
|
||||
}
|
||||
if !r.Done() {
|
||||
t.Errorf("%s: the decision was never taken", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ type Grace struct {
|
||||
mu sync.Mutex
|
||||
resolved bool
|
||||
crashBoot bool
|
||||
bootAt time.Time // the unclean boot this start followed (set only when crashBoot)
|
||||
}
|
||||
|
||||
// New builds the grace for a controller that started at start. probe may be nil (no agent): unknown.
|
||||
@@ -116,10 +117,24 @@ func (g *Grace) tryResolve(final bool) {
|
||||
g.resolve(false, "the host's last unclean boot ("+f.BootAt.UTC().Format(time.RFC3339)+") is not the one this start followed")
|
||||
default:
|
||||
g.crashBoot = true
|
||||
g.bootAt = f.BootAt
|
||||
g.resolve(true, "the host's last boot ("+f.BootAt.UTC().Format(time.RFC3339)+") followed an UNCLEAN stop")
|
||||
}
|
||||
}
|
||||
|
||||
// Decided reports the run's verdict once it is known (R-1007): resolved=false while the fact may still arrive;
|
||||
// then crash (and the unclean boot's time) or not. It asks the probe like Within does — unknown resolves to a normal
|
||||
// boot at the end of the normal grace, never to a crash. Pinned by TestDecided_* in crashboot_test.go.
|
||||
func (g *Grace) Decided(now time.Time) (resolved, crash bool, bootAt time.Time) {
|
||||
g.duration(now.Sub(g.start))
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.resolved, g.crashBoot, g.bootAt
|
||||
}
|
||||
|
||||
// Start is the controller start this grace measures from.
|
||||
func (g *Grace) Start() time.Time { return g.start }
|
||||
|
||||
func (g *Grace) resolve(crash bool, why string) {
|
||||
g.resolved = true
|
||||
if g.logger == nil {
|
||||
|
||||
@@ -104,3 +104,20 @@ func TestR856_AgentProbeReadsTheCrashGuardState(t *testing.T) {
|
||||
t.Error("an agent that predates the route (404) is unknown — a normal boot")
|
||||
}
|
||||
}
|
||||
|
||||
// R-1007: Decided exposes the same verdict, with the unclean boot's time. Unknown stays unresolved inside the
|
||||
// normal grace and resolves to NOT a crash at its end.
|
||||
func TestDecided_CrashCarriesTheBootTimeUnknownResolvesNormal(t *testing.T) {
|
||||
boot := start.Add(-time.Minute)
|
||||
g := New(start, fixed(Fact{Known: true, Unclean: true, BootAt: boot}, nil), nil)
|
||||
if res, crash, at := g.Decided(start.Add(time.Second)); !res || !crash || !at.Equal(boot) {
|
||||
t.Fatalf("crash: resolved=%t crash=%t at=%s", res, crash, at)
|
||||
}
|
||||
u := New(start, fixed(Fact{}, nil), nil)
|
||||
if res, _, _ := u.Decided(start.Add(10 * time.Second)); res {
|
||||
t.Fatal("unknown must not resolve inside the normal grace (the agent may come up late)")
|
||||
}
|
||||
if res, crash, _ := u.Decided(start.Add(NormalGrace)); !res || crash {
|
||||
t.Fatalf("unknown at the end of the normal grace: resolved=%t crash=%t, want a normal boot", res, crash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2685,5 +2685,13 @@
|
||||
"poweroff.refused.update": "An update is running. Wait until it ends, then try again.",
|
||||
"poweroff.refused.drive": "The machine is working on a drive. Wait until it ends, then try again.",
|
||||
"poweroff.refused.other_guests": "Another system runs on this machine, so it cannot be turned off from here.",
|
||||
"poweroff.refused.generic": "The machine cannot turn itself off safely right now. Try again in a few minutes."
|
||||
"poweroff.refused.generic": "The machine cannot turn itself off safely right now. Try again in a few minutes.",
|
||||
"event.app_repaired_after_crash": "%s: after the unexpected stop, %d damaged preview file(s), %d repaired, %d still broken; lost originals: %d",
|
||||
"event.app_repair_after_crash_skipped": "%s: the repair after the unexpected stop was skipped: %s",
|
||||
"app_info.lost_files_title": "Photos lost in an unexpected stop",
|
||||
"app_info.lost_files_text": "The machine stopped unexpectedly (for example in a power cut), and these files were lost. The machine started again at:",
|
||||
"app_info.lost_files_taken": "taken:",
|
||||
"app_info.lost_files_hint": "If your phone still has them, upload them again. Check now, before your phone frees their space.",
|
||||
"app_info.lost_files_dismiss_btn": "OK, I have read this",
|
||||
"err.lost_files.nothing_to_dismiss": "There is no note about lost files for this app."
|
||||
}
|
||||
|
||||
@@ -2673,5 +2673,13 @@
|
||||
"poweroff.refused.update": "Most frissítés fut. Várd meg, amíg befejeződik, és próbáld újra.",
|
||||
"poweroff.refused.drive": "A gép most egy meghajtón dolgozik. Várd meg, amíg befejeződik, és próbáld újra.",
|
||||
"poweroff.refused.other_guests": "Ezen a gépen más rendszer is fut, ezért innen nem lehet kikapcsolni.",
|
||||
"poweroff.refused.generic": "A gép most nem tudja biztonságosan kikapcsolni magát. Próbáld újra néhány perc múlva."
|
||||
"poweroff.refused.generic": "A gép most nem tudja biztonságosan kikapcsolni magát. Próbáld újra néhány perc múlva.",
|
||||
"event.app_repaired_after_crash": "%s: a váratlan leállás után %d sérült előnézeti fájl, ebből %d javítva, %d maradt hibás; elveszett eredeti fájl: %d",
|
||||
"event.app_repair_after_crash_skipped": "%s: a váratlan leállás utáni javítás elmaradt: %s",
|
||||
"app_info.lost_files_title": "Elveszett fotók egy váratlan leállás miatt",
|
||||
"app_info.lost_files_text": "A gép váratlanul leállt (például áramszünet miatt), és ezek a fájlok elvesztek. A gép újraindulásának ideje:",
|
||||
"app_info.lost_files_taken": "készült:",
|
||||
"app_info.lost_files_hint": "Ha a telefonodon még megvannak, töltsd fel őket újra. Nézd meg most, mielőtt a telefon felszabadítja a helyüket.",
|
||||
"app_info.lost_files_dismiss_btn": "Rendben, elolvastam",
|
||||
"err.lost_files.nothing_to_dismiss": "Ehhez az alkalmazáshoz nincs elveszett fájlokról szóló értesítés."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// R-1007 (decision 236) — app_repaired_after_crash carries COUNTS ONLY to the operator. A file name or photo data in
|
||||
// the payload would leave the household's box. Asserted on the marshalled payload, and on the struct's fields (a new
|
||||
// field must come through this test).
|
||||
// RED-PROOF: add `Names []string json:"names"` to AppRepairDetails → the field list test fails.
|
||||
func TestAppRepairedAfterCrash_PayloadIsCountsOnly(t *testing.T) {
|
||||
var fields []string
|
||||
rt := reflect.TypeOf(AppRepairDetails{})
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
fields = append(fields, strings.Split(rt.Field(i).Tag.Get("json"), ",")[0])
|
||||
}
|
||||
sort.Strings(fields)
|
||||
want := "app,boot_at,duration_s,found,lost,lost_checked,problem,repaired,skipped,stack_name,still_broken"
|
||||
if strings.Join(fields, ",") != want {
|
||||
t.Fatalf("AppRepairDetails fields = %v, want exactly %s — counts only, never a file name", fields, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The severity: lost or still broken → warning; everything repaired → info; nothing found and nothing lost → no
|
||||
// event at all. RED-PROOF: send `info` when Lost > 0 → the lost case reads info → fails.
|
||||
func TestAppRepairedAfterCrash_SeverityByOutcome(t *testing.T) {
|
||||
n := &Notifier{}
|
||||
var got []string
|
||||
var payloads []string
|
||||
n.pushFn = func(eventType, severity, msg, _ string, d interface{}) {
|
||||
b, _ := json.Marshal(d)
|
||||
got = append(got, eventType+"/"+severity)
|
||||
payloads = append(payloads, string(b)+" "+msg)
|
||||
}
|
||||
n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Found: 81, Repaired: 81})
|
||||
n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Found: 81, Repaired: 81, Lost: 1, LostChecked: true})
|
||||
n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Found: 5, Repaired: 4, StillBroken: 1})
|
||||
n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Skipped: "immich-server runs v3.3.0, which the template has not proven"})
|
||||
n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", LostChecked: true})
|
||||
want := []string{"app_repaired_after_crash/info", "app_repaired_after_crash/warning", "app_repaired_after_crash/warning", "app_repaired_after_crash/warning"}
|
||||
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("events %v, want %v (and nothing for a run that found nothing)", got, want)
|
||||
}
|
||||
for _, p := range payloads {
|
||||
if strings.Contains(p, ".jpg") || strings.Contains(p, "IMG_") {
|
||||
t.Fatalf("a file name in the event: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The three keys R-1007 was born with, in BOTH languages, with the same placeholders (an English box gets English).
|
||||
func TestAppRepairedAfterCrash_KeysInBothLanguages(t *testing.T) {
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range []string{"event.app_repaired_after_crash", "event.app_repair_after_crash_skipped", "err.lost_files.nothing_to_dismiss"} {
|
||||
hu, en := b.Msg("hu", k), b.Msg("en", k)
|
||||
if hu == "" || en == "" || hu == k || en == k || hu == en {
|
||||
t.Fatalf("%s: hu=%q en=%q", k, hu, en)
|
||||
}
|
||||
if strings.Count(hu, "%") != strings.Count(en, "%") {
|
||||
t.Fatalf("%s: the two languages take different arguments", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +851,36 @@ type oomRun struct {
|
||||
|
||||
// OOMStormDetails is the app_oom_storm payload. stack_name lets the hub's per-app cooldown tell two
|
||||
// storming apps apart.
|
||||
// AppRepairDetails is app_repaired_after_crash's payload (R-1007, decision 236): COUNTS ONLY — never a file name,
|
||||
// never photo data. The household's list of lost files stays on its box (app.yaml), shown on the app page.
|
||||
type AppRepairDetails struct {
|
||||
App string `json:"app"`
|
||||
StackName string `json:"stack_name"`
|
||||
BootAt string `json:"boot_at,omitempty"`
|
||||
Found int `json:"found"`
|
||||
Repaired int `json:"repaired"`
|
||||
StillBroken int `json:"still_broken"`
|
||||
Lost int `json:"lost"`
|
||||
LostChecked bool `json:"lost_checked"`
|
||||
DurationS int64 `json:"duration_s"`
|
||||
Skipped string `json:"skipped,omitempty"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
}
|
||||
|
||||
// NotifyAppRepairedAfterCrash tells the OPERATOR what an app's after_unclean_boot run did (operator-only at the hub).
|
||||
// warning: a file is lost or still broken, a step failed, or the run was skipped; info: everything found was
|
||||
// repaired (recorded at the hub, not mailed). Nothing found and nothing lost: no event.
|
||||
func (n *Notifier) NotifyAppRepairedAfterCrash(d AppRepairDetails) {
|
||||
switch {
|
||||
case d.Skipped != "":
|
||||
n.pushEventMsg("app_repaired_after_crash", "warning", "event.app_repair_after_crash_skipped", d, d.App, d.Skipped)
|
||||
case d.Lost > 0 || d.StillBroken > 0 || d.Problem != "":
|
||||
n.pushEventMsg("app_repaired_after_crash", "warning", "event.app_repaired_after_crash", d, d.App, d.Found, d.Repaired, d.StillBroken, d.Lost)
|
||||
case d.Found > 0:
|
||||
n.pushEventMsg("app_repaired_after_crash", "info", "event.app_repaired_after_crash", d, d.App, d.Found, d.Repaired, d.StillBroken, d.Lost)
|
||||
}
|
||||
}
|
||||
|
||||
type OOMStormDetails struct {
|
||||
App string `json:"app"`
|
||||
StackName string `json:"stack_name"`
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── after_unclean_boot (R-1007, `09` §3 decision 236): an app repairs the files a power cut left empty ──
|
||||
//
|
||||
// A power cut while photos upload leaves some of Immich's thumbnails and previews ZERO BYTES (ext4 had the name, not
|
||||
// the data). Immich still lists the photos as done and never redoes them; rarely an original is gone too. The
|
||||
// template declares, for its own app, where such files are, how the app is asked to rebuild THOSE (and only those),
|
||||
// and how the app's own lost-file check is read:
|
||||
//
|
||||
// after_unclean_boot:
|
||||
// service: immich-server # the commands run in this compose service
|
||||
// images: ["ghcr.io/immich-app/immich-server:v3.2.4"] # the images it was PROVEN on; any other: skip
|
||||
// empty_files:
|
||||
// under: "${HDD_PATH}/appdata/immich/thumbs" # only ${HDD_PATH} is filled in
|
||||
// id_from_name: "^([0-9a-f-]{36})_" # one capture group: the app's id for that file
|
||||
// repair:
|
||||
// command: ["node", "-e", "…"] # the ids are appended as arguments
|
||||
// success: FELHOM_REPAIR_OK
|
||||
// batch: 200
|
||||
// report_lost: # optional
|
||||
// command: ["node", "-e", "…"] # prints `LOST {"name":…,"taken":…}` lines
|
||||
// success: FELHOM_LOST_OK
|
||||
//
|
||||
// The controller holds NO app knowledge: the catalog block is all of it. Ran ONLY once per controller start that
|
||||
// followed an unclean host boot (internal/aftercrash decides when). Never deletes or rewrites anything itself; the
|
||||
// app rebuilds its own files. A run that fails never stops the app. File names never reach a log line or an event:
|
||||
// the lost list lives only in app.yaml on this box, for the household's note. Pinned by after_unclean_boot_test.go.
|
||||
|
||||
// AfterUncleanBootSpec is `.felhom.yml`'s `after_unclean_boot:`.
|
||||
type AfterUncleanBootSpec struct {
|
||||
Service string `yaml:"service" json:"service"`
|
||||
Images []string `yaml:"images" json:"images"`
|
||||
EmptyFiles AUBEmptyFiles `yaml:"empty_files" json:"empty_files"`
|
||||
Repair AUBCommand `yaml:"repair" json:"repair"`
|
||||
ReportLost *AUBCommand `yaml:"report_lost,omitempty" json:"report_lost,omitempty"`
|
||||
Extra map[string]any `yaml:",inline" json:"-"` // a newer key this box does not know → skip
|
||||
}
|
||||
|
||||
// AUBEmptyFiles says where the empty files are and how a file name maps to the app's id.
|
||||
type AUBEmptyFiles struct {
|
||||
Under string `yaml:"under" json:"under"`
|
||||
IDFromName string `yaml:"id_from_name" json:"id_from_name"`
|
||||
}
|
||||
|
||||
// AUBCommand is one command run in the service's container.
|
||||
type AUBCommand struct {
|
||||
Command []string `yaml:"command" json:"command"`
|
||||
Success string `yaml:"success" json:"success"`
|
||||
Batch int `yaml:"batch,omitempty" json:"batch,omitempty"`
|
||||
}
|
||||
|
||||
// LostFile is one file the app's own check reports lost: the household's note shows it, nothing else does.
|
||||
type LostFile struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Taken string `yaml:"taken,omitempty" json:"taken,omitempty"`
|
||||
}
|
||||
|
||||
// AfterUncleanBootRecord is app.yaml's `after_unclean_boot:` — what the last run found and did.
|
||||
type AfterUncleanBootRecord struct {
|
||||
At string `yaml:"at" json:"at"`
|
||||
BootAt string `yaml:"boot_at,omitempty" json:"boot_at,omitempty"`
|
||||
Found int `yaml:"found" json:"found"`
|
||||
Repaired int `yaml:"repaired" json:"repaired"`
|
||||
StillBroken int `yaml:"still_broken" json:"still_broken"`
|
||||
Lost []LostFile `yaml:"lost,omitempty" json:"lost,omitempty"`
|
||||
LostChecked bool `yaml:"lost_checked" json:"lost_checked"`
|
||||
DurationMS int64 `yaml:"duration_ms" json:"duration_ms"`
|
||||
Skipped string `yaml:"skipped,omitempty" json:"skipped,omitempty"`
|
||||
Problem string `yaml:"problem,omitempty" json:"problem,omitempty"`
|
||||
Dismissed bool `yaml:"dismissed,omitempty" json:"dismissed,omitempty"`
|
||||
}
|
||||
|
||||
// AfterUncleanBootResult is a run's outcome for the log and the operator event (counts only).
|
||||
type AfterUncleanBootResult struct {
|
||||
Found, Repaired, StillBroken, Lost int
|
||||
LostChecked bool
|
||||
Duration time.Duration
|
||||
Skipped string // why nothing ran ("" = it ran)
|
||||
Problem string // a step that failed (the counts say what was done)
|
||||
}
|
||||
|
||||
var (
|
||||
aubIDSafe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
|
||||
aubHealthyWait = 10 * time.Minute
|
||||
aubDefaultBatch = 200
|
||||
aubMaxLostListed = 200
|
||||
)
|
||||
|
||||
// aubUnsupported names why this box cannot run the block, or "" when it can.
|
||||
func aubUnsupported(s *AfterUncleanBootSpec) string {
|
||||
if len(s.Extra) > 0 {
|
||||
keys := make([]string, 0, len(s.Extra))
|
||||
for k := range s.Extra {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return "the template has keys this controller does not know: " + strings.Join(keys, ", ")
|
||||
}
|
||||
switch {
|
||||
case s.Service == "" || len(s.Images) == 0:
|
||||
return "the template names no service or no proven image"
|
||||
case s.EmptyFiles.Under == "" || s.EmptyFiles.IDFromName == "":
|
||||
return "the template names no folder or no id pattern"
|
||||
case len(s.Repair.Command) == 0 || s.Repair.Success == "":
|
||||
return "the template names no repair command"
|
||||
case s.ReportLost != nil && (len(s.ReportLost.Command) == 0 || s.ReportLost.Success == ""):
|
||||
return "the template's lost-file check is incomplete"
|
||||
}
|
||||
re, err := regexp.Compile(s.EmptyFiles.IDFromName)
|
||||
if err != nil || re.NumSubexp() != 1 {
|
||||
return "the template's id pattern is not one capture group"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// aubUnder fills ${HDD_PATH} (and nothing else) into the folder; an empty or relative result refuses.
|
||||
func aubUnder(tmpl string, env map[string]string) (string, error) {
|
||||
var bad []string
|
||||
out := os.Expand(tmpl, func(k string) string {
|
||||
if k != "HDD_PATH" || strings.TrimSpace(env["HDD_PATH"]) == "" {
|
||||
bad = append(bad, k)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(env["HDD_PATH"])
|
||||
})
|
||||
if len(bad) > 0 {
|
||||
return "", fmt.Errorf("the folder needs %v, which this app does not have", bad)
|
||||
}
|
||||
out = filepath.Clean(out)
|
||||
if !filepath.IsAbs(out) || out == "/" {
|
||||
return "", fmt.Errorf("the folder %q is not an absolute app folder", out)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ScanEmptyFiles walks root and returns the ids (de-duplicated, sorted) of the REGULAR, zero-byte files whose names
|
||||
// match re. Symlinks are never followed nor counted; the walk stays on root's own filesystem; a name whose id is not
|
||||
// plain id text is skipped. A missing root is no error (nothing to repair).
|
||||
func ScanEmptyFiles(root string, re *regexp.Regexp) ([]string, error) {
|
||||
rootInfo, err := os.Lstat(root)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !rootInfo.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a folder", root)
|
||||
}
|
||||
rootDev, haveDev := devOf(rootInfo)
|
||||
seen := map[string]bool{}
|
||||
err = filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
|
||||
if werr != nil {
|
||||
if p == root {
|
||||
return werr
|
||||
}
|
||||
return nil // an unreadable corner is skipped, not fatal
|
||||
}
|
||||
if d.Type()&fs.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
info, ierr := d.Info()
|
||||
if ierr != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if haveDev && p != root {
|
||||
if dev, ok := devOf(info); ok && dev != rootDev {
|
||||
return filepath.SkipDir // another filesystem mounted inside: not the app's folder
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() != 0 {
|
||||
return nil
|
||||
}
|
||||
m := re.FindStringSubmatch(d.Name())
|
||||
if len(m) != 2 || !aubIDSafe.MatchString(m[1]) {
|
||||
return nil
|
||||
}
|
||||
seen[m[1]] = true
|
||||
return nil
|
||||
})
|
||||
ids := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids, err
|
||||
}
|
||||
|
||||
func devOf(info fs.FileInfo) (uint64, bool) {
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
return uint64(st.Dev), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// parseLost reads the lost-file check's `LOST {json}` lines.
|
||||
func parseLost(out string) []LostFile {
|
||||
var lost []LostFile
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
rest, ok := strings.CutPrefix(strings.TrimSpace(line), "LOST ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var f LostFile
|
||||
if json.Unmarshal([]byte(rest), &f) == nil && strings.TrimSpace(f.Name) != "" {
|
||||
f.Name = truncateStr(f.Name, 200)
|
||||
f.Taken = truncateStr(f.Taken, 40)
|
||||
lost = append(lost, f)
|
||||
}
|
||||
}
|
||||
return lost
|
||||
}
|
||||
|
||||
// AfterUncleanBootApps lists the deployed apps whose template carries the block.
|
||||
func (m *Manager) AfterUncleanBootApps() []string {
|
||||
var out []string
|
||||
for _, st := range m.GetStacks() {
|
||||
if st.Deployed && st.Meta.AfterUncleanBoot != nil {
|
||||
out = append(out, st.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// RunAfterUncleanBoot runs one app's block once (internal/aftercrash calls it after an unclean boot). It records
|
||||
// the outcome in app.yaml and returns the counts. It never stops, restarts or changes the app.
|
||||
func (m *Manager) RunAfterUncleanBoot(name string, bootAt time.Time) AfterUncleanBootResult {
|
||||
t0 := time.Now()
|
||||
res := AfterUncleanBootResult{}
|
||||
st, ok := m.GetStack(name)
|
||||
if !ok || st.Meta.AfterUncleanBoot == nil {
|
||||
res.Skipped = "the app has no after_unclean_boot block"
|
||||
return res
|
||||
}
|
||||
spec := st.Meta.AfterUncleanBoot
|
||||
dir := filepath.Dir(st.ComposePath)
|
||||
var lost []LostFile
|
||||
defer func() {
|
||||
res.Duration = time.Since(t0)
|
||||
rec := &AfterUncleanBootRecord{
|
||||
At: m.now().UTC().Format(time.RFC3339), Found: res.Found, Repaired: res.Repaired,
|
||||
StillBroken: res.StillBroken, Lost: lost, LostChecked: res.LostChecked,
|
||||
DurationMS: res.Duration.Milliseconds(), Skipped: res.Skipped, Problem: truncateStr(res.Problem, 300),
|
||||
}
|
||||
if !bootAt.IsZero() {
|
||||
rec.BootAt = bootAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
m.mutateAppConfig(name, dir, "after_unclean_boot", func(cfg *AppConfig) bool { cfg.AfterUncleanBoot = rec; return true })
|
||||
}()
|
||||
|
||||
if why := aubUnsupported(spec); why != "" {
|
||||
res.Skipped = why
|
||||
return res
|
||||
}
|
||||
cfg := LoadAppConfigDecrypted(dir, m.encKey)
|
||||
if cfg == nil {
|
||||
res.Skipped = "the app's settings could not be read"
|
||||
return res
|
||||
}
|
||||
if img := installedRef(cfg, spec.Service); !containsString(spec.Images, img) {
|
||||
if img == "" {
|
||||
img = "unknown"
|
||||
}
|
||||
res.Skipped = fmt.Sprintf("%s runs %s, which the template has not proven (%s)", spec.Service, img, strings.Join(spec.Images, ", "))
|
||||
return res
|
||||
}
|
||||
root, err := aubUnder(spec.EmptyFiles.Under, cfg.Env)
|
||||
if err != nil {
|
||||
res.Skipped = err.Error()
|
||||
return res
|
||||
}
|
||||
re := regexp.MustCompile(spec.EmptyFiles.IDFromName)
|
||||
|
||||
// The app must be up: it rebuilds its own files.
|
||||
deadline := time.Now().Add(aubHealthyWait)
|
||||
for {
|
||||
if m.aubRunning(name) {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
res.Skipped = fmt.Sprintf("the app was not running and healthy within %s", aubHealthyWait)
|
||||
return res
|
||||
}
|
||||
m.aubSleep(5 * time.Second)
|
||||
}
|
||||
|
||||
ids, err := ScanEmptyFiles(root, re)
|
||||
if err != nil {
|
||||
res.Problem = "the scan failed: " + err.Error()
|
||||
return res
|
||||
}
|
||||
res.Found = len(ids)
|
||||
if len(ids) > 0 {
|
||||
still := ids
|
||||
for attempt := 1; attempt <= 2 && len(still) > 0; attempt++ { // the repair, and at most ONE retry
|
||||
if perr := m.aubRepair(dir, spec, still); perr != nil {
|
||||
res.Problem = perr.Error()
|
||||
}
|
||||
after, serr := ScanEmptyFiles(root, re)
|
||||
if serr != nil {
|
||||
res.Problem = "the re-scan failed: " + serr.Error()
|
||||
break
|
||||
}
|
||||
still = intersect(still, after)
|
||||
}
|
||||
res.StillBroken = len(still)
|
||||
res.Repaired = res.Found - res.StillBroken
|
||||
}
|
||||
if spec.ReportLost != nil {
|
||||
out, lerr := m.aubExec(dir, spec.Service, spec.ReportLost.Command)
|
||||
switch {
|
||||
case lerr != nil || !strings.Contains(out, spec.ReportLost.Success):
|
||||
if res.Problem == "" {
|
||||
res.Problem = fmt.Sprintf("the lost-file check did not finish (err=%v)", lerr)
|
||||
}
|
||||
default:
|
||||
res.LostChecked = true
|
||||
lost = parseLost(out)
|
||||
res.Lost = len(lost)
|
||||
if len(lost) > aubMaxLostListed {
|
||||
lost = lost[:aubMaxLostListed]
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (m *Manager) aubRepair(dir string, spec *AfterUncleanBootSpec, ids []string) error {
|
||||
batch := spec.Repair.Batch
|
||||
if batch <= 0 {
|
||||
batch = aubDefaultBatch
|
||||
}
|
||||
for i := 0; i < len(ids); i += batch {
|
||||
j := min(i+batch, len(ids))
|
||||
cmd := append(append([]string{}, spec.Repair.Command...), ids[i:j]...)
|
||||
out, err := m.aubExec(dir, spec.Service, cmd)
|
||||
if err != nil || !strings.Contains(out, spec.Repair.Success) {
|
||||
return fmt.Errorf("the repair command did not finish for %d id(s) (err=%v)", j-i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// aubExec runs one command in the service's container (the after_load seam; tests replace afterLoadFn). The output
|
||||
// may carry file names: it is returned, never logged.
|
||||
func (m *Manager) aubExec(dir, service string, cmd []string) (string, error) {
|
||||
args := append([]string{"exec", "-T", service}, cmd...)
|
||||
return m.afterLoadExec(dir, args...)
|
||||
}
|
||||
|
||||
// aubRunning: the app is up (its own containers running and healthy). Seam: aubRunningFn.
|
||||
func (m *Manager) aubRunning(name string) bool {
|
||||
if m.aubRunningFn != nil {
|
||||
return m.aubRunningFn(name)
|
||||
}
|
||||
_ = m.RefreshStatus()
|
||||
s, ok := m.GetStack(name)
|
||||
return ok && s.State == StateRunning
|
||||
}
|
||||
|
||||
func (m *Manager) aubSleep(d time.Duration) {
|
||||
if m.aubSleepFn != nil {
|
||||
m.aubSleepFn(d)
|
||||
return
|
||||
}
|
||||
time.Sleep(d)
|
||||
}
|
||||
|
||||
// DismissLostFiles hides the household's note (the record stays for the operator's counts).
|
||||
func (m *Manager) DismissLostFiles(name string) error {
|
||||
st, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
dir := filepath.Dir(st.ComposePath)
|
||||
var had bool
|
||||
m.mutateAppConfig(name, dir, "after_unclean_boot_dismiss", func(cfg *AppConfig) bool {
|
||||
if cfg.AfterUncleanBoot == nil || len(cfg.AfterUncleanBoot.Lost) == 0 {
|
||||
return false
|
||||
}
|
||||
had = true
|
||||
cfg.AfterUncleanBoot.Dismissed = true
|
||||
return true
|
||||
})
|
||||
if !had {
|
||||
return fmt.Errorf("no lost-file note for %s", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installedRef(cfg *AppConfig, service string) string {
|
||||
if cfg == nil || cfg.InstalledImages == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.InstalledImages[service].Ref
|
||||
}
|
||||
|
||||
func containsString(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s && s != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func intersect(a, b []string) []string {
|
||||
in := map[string]bool{}
|
||||
for _, x := range b {
|
||||
in[x] = true
|
||||
}
|
||||
var out []string
|
||||
for _, x := range a {
|
||||
if in[x] {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// R-1007 (decision 236) — after_unclean_boot: the app rebuilds ONLY the files a power cut left empty, and its own
|
||||
// lost-file check is read. The exec is the seam; nothing reaches Docker. Each guard's red-proof is named on its test.
|
||||
|
||||
const (
|
||||
idA = "11111111-1111-4111-8111-111111111111"
|
||||
idB = "22222222-2222-4222-8222-222222222222"
|
||||
idC = "33333333-3333-4333-8333-333333333333"
|
||||
idD = "44444444-4444-4444-8444-444444444444"
|
||||
)
|
||||
|
||||
var aubRe = regexp.MustCompile(`^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_`)
|
||||
|
||||
func aubWrite(t *testing.T, p string, data string) {
|
||||
t.Helper()
|
||||
must(t, os.MkdirAll(filepath.Dir(p), 0o755))
|
||||
must(t, os.WriteFile(p, []byte(data), 0o644))
|
||||
}
|
||||
|
||||
// RED-PROOF: drop the `info.Size() != 0` test in ScanEmptyFiles → idB (non-empty) is sent → fails.
|
||||
// RED-PROOF: drop the ModeSymlink skip → the link to an empty file OUTSIDE the folder is counted → fails.
|
||||
func TestAUB_ScanFindsOnlyEmptyRegularFilesInsideTheFolder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := filepath.Join(dir, "thumbs")
|
||||
aubWrite(t, filepath.Join(root, "u1/11/11", idA+"_thumbnail.webp"), "") // empty: found
|
||||
aubWrite(t, filepath.Join(root, "u1/11/11", idA+"_preview.jpeg"), "") // the same id twice: once
|
||||
aubWrite(t, filepath.Join(root, "u1/22/22", idB+"_thumbnail.webp"), "data") // the non-empty CONTROL
|
||||
aubWrite(t, filepath.Join(root, "u2/33/33", idC+"_preview.jpeg"), "") // another user: found
|
||||
aubWrite(t, filepath.Join(root, "u2/notes.txt"), "") // an empty file with no id
|
||||
aubWrite(t, filepath.Join(root, "u2", "-rf_thumbnail.webp"), "") // a name that is no plain id
|
||||
aubWrite(t, filepath.Join(dir, "outside", idD+"_thumbnail.webp"), "") // empty, but OUTSIDE the folder
|
||||
must(t, os.Symlink(filepath.Join(dir, "outside", idD+"_thumbnail.webp"), filepath.Join(root, "u2", idD+"_thumbnail.webp")))
|
||||
ids, err := ScanEmptyFiles(root, aubRe)
|
||||
must(t, err)
|
||||
if strings.Join(ids, ",") != idA+","+idC {
|
||||
t.Fatalf("ids = %v, want exactly [%s %s]", ids, idA, idC)
|
||||
}
|
||||
if ids, err := ScanEmptyFiles(filepath.Join(dir, "nope"), aubRe); err != nil || len(ids) != 0 {
|
||||
t.Fatalf("a missing folder: %v %v", ids, err)
|
||||
}
|
||||
}
|
||||
|
||||
// aubManager: the kept-test manager with an Immich-shaped block on "cloudapp", running, its image proven.
|
||||
func aubManager(t *testing.T) (*Manager, string, *AfterUncleanBootSpec, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
m, drive := keptManager(t)
|
||||
var logBuf bytes.Buffer
|
||||
m.logger = log.New(&logBuf, "", 0)
|
||||
dir := filepath.Dir(m.stacks["cloudapp"].ComposePath)
|
||||
must(t, SaveAppConfig(dir, &AppConfig{Deployed: true, Env: map[string]string{"HDD_PATH": drive},
|
||||
InstalledImages: map[string]InstalledImage{"cloudapp": {Ref: "example/app:v1"}}}, m.encKey, nil))
|
||||
spec := &AfterUncleanBootSpec{
|
||||
Service: "cloudapp", Images: []string{"example/app:v1"},
|
||||
EmptyFiles: AUBEmptyFiles{Under: "${HDD_PATH}/appdata/cloudapp/thumbs", IDFromName: aubRe.String()},
|
||||
Repair: AUBCommand{Command: []string{"node", "-e", "repair"}, Success: "FELHOM_REPAIR_OK", Batch: 2},
|
||||
ReportLost: &AUBCommand{Command: []string{"node", "-e", "lost"}, Success: "FELHOM_LOST_OK"},
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.stacks["cloudapp"].Meta.AfterUncleanBoot = spec
|
||||
m.stacks["cloudapp"].Deployed = true
|
||||
m.mu.Unlock()
|
||||
m.aubRunningFn = func(string) bool { return true }
|
||||
m.aubSleepFn = func(time.Duration) {}
|
||||
return m, drive, spec, &logBuf
|
||||
}
|
||||
|
||||
// fakeApp plays the app: the repair fills the files of the ids it is given (unless told to leave one), the lost
|
||||
// check prints its report. It records every command.
|
||||
type fakeApp struct {
|
||||
root string
|
||||
leave map[string]bool // ids whose files the app fails to rebuild
|
||||
calls [][]string
|
||||
lostOut string
|
||||
repairErr error
|
||||
}
|
||||
|
||||
func (f *fakeApp) exec(_ string, args ...string) (string, error) {
|
||||
f.calls = append(f.calls, append([]string(nil), args...))
|
||||
if len(args) >= 6 && args[5] == "repair" {
|
||||
for _, id := range args[6:] {
|
||||
if f.leave[id] {
|
||||
continue
|
||||
}
|
||||
_ = filepath.Walk(f.root, func(p string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() && strings.HasPrefix(info.Name(), id+"_") {
|
||||
_ = os.WriteFile(p, []byte("rebuilt"), 0o644)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if f.repairErr != nil {
|
||||
return "boom", f.repairErr
|
||||
}
|
||||
return "FELHOM_REPAIR_OK", nil
|
||||
}
|
||||
return f.lostOut, nil
|
||||
}
|
||||
|
||||
func (f *fakeApp) repairCalls() [][]string {
|
||||
var out [][]string
|
||||
for _, c := range f.calls {
|
||||
if len(c) >= 6 && c[5] == "repair" {
|
||||
out = append(out, c[6:])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The consequence: only the empty files' ids are sent; the non-empty control is not; batches hold; one still-empty
|
||||
// file is retried ONCE and then reported; the lost list is exactly the app's report; the log carries no file name.
|
||||
// RED-PROOF: let the retry loop run 3 times (`attempt <= 3`) → the still-broken id is sent 3 times → fails.
|
||||
// RED-PROOF: drop the intersect() (re-scan) → StillBroken stays 0 for the file the app never rebuilt → fails.
|
||||
func TestAUB_RepairsOnlyTheEmptyFilesRetriesOnceAndReadsTheLostList(t *testing.T) {
|
||||
m, drive, _, logBuf := aubManager(t)
|
||||
root := filepath.Join(drive, "appdata/cloudapp/thumbs")
|
||||
aubWrite(t, filepath.Join(root, "u1", idA+"_thumbnail.webp"), "")
|
||||
aubWrite(t, filepath.Join(root, "u1", idB+"_preview.jpeg"), "real") // control
|
||||
aubWrite(t, filepath.Join(root, "u2", idC+"_preview.jpeg"), "")
|
||||
aubWrite(t, filepath.Join(root, "u2", idD+"_thumbnail.webp"), "")
|
||||
ctlInfo, _ := os.Stat(filepath.Join(root, "u1", idB+"_preview.jpeg"))
|
||||
app := &fakeApp{root: root, leave: map[string]bool{idD: true},
|
||||
lostOut: "LOST {\"name\":\"IMG_0042.jpg\",\"taken\":\"2026-10-14 10:41\"}\nnoise\nFELHOM_LOST_OK lost=1\n"}
|
||||
m.afterLoadFn = app.exec
|
||||
boot := time.Date(2026, 10, 14, 12, 7, 26, 0, time.UTC)
|
||||
|
||||
res := m.RunAfterUncleanBoot("cloudapp", boot)
|
||||
|
||||
if res.Skipped != "" || res.Problem != "" {
|
||||
t.Fatalf("skipped=%q problem=%q", res.Skipped, res.Problem)
|
||||
}
|
||||
if res.Found != 3 || res.Repaired != 2 || res.StillBroken != 1 || res.Lost != 1 || !res.LostChecked {
|
||||
t.Fatalf("counts %+v, want found 3, repaired 2, still 1, lost 1", res)
|
||||
}
|
||||
sent := map[string]int{}
|
||||
for _, batch := range app.repairCalls() {
|
||||
if len(batch) > 2 {
|
||||
t.Fatalf("a batch of %d, the template says 2", len(batch))
|
||||
}
|
||||
for _, id := range batch {
|
||||
sent[id]++
|
||||
}
|
||||
}
|
||||
if sent[idB] != 0 {
|
||||
t.Fatal("the non-empty control was sent for repair")
|
||||
}
|
||||
if sent[idA] != 1 || sent[idC] != 1 || sent[idD] != 2 {
|
||||
t.Fatalf("sent %v: the repaired ones once, the still-broken one twice (one retry)", sent)
|
||||
}
|
||||
if after, _ := os.Stat(filepath.Join(root, "u1", idB+"_preview.jpeg")); !after.ModTime().Equal(ctlInfo.ModTime()) {
|
||||
t.Fatal("the control file was rewritten")
|
||||
}
|
||||
c := LoadAppConfig(filepath.Dir(m.stacks["cloudapp"].ComposePath))
|
||||
r := c.AfterUncleanBoot
|
||||
if r == nil || r.Found != 3 || r.StillBroken != 1 || r.BootAt != "2026-10-14T12:07:26Z" {
|
||||
t.Fatalf("record %+v", r)
|
||||
}
|
||||
if len(r.Lost) != 1 || r.Lost[0].Name != "IMG_0042.jpg" || r.Lost[0].Taken != "2026-10-14 10:41" {
|
||||
t.Fatalf("the household's lost list %+v, want exactly the app's report", r.Lost)
|
||||
}
|
||||
if strings.Contains(logBuf.String(), "IMG_0042") {
|
||||
t.Fatal("a lost file's name reached the controller log")
|
||||
}
|
||||
}
|
||||
|
||||
// A run never guesses: an image the template has not proven, a key this box does not know, a stopped app — each is
|
||||
// skipped with its reason and NO command runs. RED-PROOF: drop the containsString(spec.Images, …) check → the
|
||||
// unproven image's run sends commands → fails.
|
||||
func TestAUB_SkipsAnUnprovenImageAnUnknownKeyAndAStoppedApp(t *testing.T) {
|
||||
m, drive, spec, _ := aubManager(t)
|
||||
aubWrite(t, filepath.Join(drive, "appdata/cloudapp/thumbs/u1", idA+"_thumbnail.webp"), "")
|
||||
app := &fakeApp{root: drive}
|
||||
m.afterLoadFn = app.exec
|
||||
|
||||
spec.Images = []string{"example/app:v2"}
|
||||
if res := m.RunAfterUncleanBoot("cloudapp", time.Time{}); !strings.Contains(res.Skipped, "has not proven") || len(app.calls) != 0 {
|
||||
t.Fatalf("unproven image: %+v, calls %v", res, app.calls)
|
||||
}
|
||||
spec.Images = []string{"example/app:v1"}
|
||||
spec.Extra = map[string]any{"verify_after": true}
|
||||
if res := m.RunAfterUncleanBoot("cloudapp", time.Time{}); !strings.Contains(res.Skipped, "verify_after") || len(app.calls) != 0 {
|
||||
t.Fatalf("unknown key: %+v, calls %v", res, app.calls)
|
||||
}
|
||||
spec.Extra = nil
|
||||
m.aubRunningFn = func(string) bool { return false }
|
||||
aubHealthyWait = 0
|
||||
t.Cleanup(func() { aubHealthyWait = 10 * time.Minute })
|
||||
if res := m.RunAfterUncleanBoot("cloudapp", time.Time{}); !strings.Contains(res.Skipped, "not running") || len(app.calls) != 0 {
|
||||
t.Fatalf("stopped app: %+v, calls %v", res, app.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A failing repair is a reported problem, never a stopped app or an endless loop.
|
||||
func TestAUB_AFailingRepairIsReportedNotRetriedForever(t *testing.T) {
|
||||
m, drive, _, _ := aubManager(t)
|
||||
root := filepath.Join(drive, "appdata/cloudapp/thumbs")
|
||||
aubWrite(t, filepath.Join(root, "u1", idA+"_thumbnail.webp"), "")
|
||||
app := &fakeApp{root: root, leave: map[string]bool{idA: true}, repairErr: errors.New("exit 1"), lostOut: "FELHOM_LOST_OK lost=0"}
|
||||
m.afterLoadFn = app.exec
|
||||
res := m.RunAfterUncleanBoot("cloudapp", time.Time{})
|
||||
if res.Problem == "" || res.StillBroken != 1 || len(app.repairCalls()) != 2 {
|
||||
t.Fatalf("%+v, repair calls %d (want the run + one retry)", res, len(app.repairCalls()))
|
||||
}
|
||||
}
|
||||
|
||||
// The catalog block parses as written, and a newer key lands in Extra (so the box skips, never half-runs).
|
||||
func TestAUB_TheCatalogShapeParses(t *testing.T) {
|
||||
src := `
|
||||
after_unclean_boot:
|
||||
service: immich-server
|
||||
images: ["ghcr.io/immich-app/immich-server:v3.2.4"]
|
||||
empty_files:
|
||||
under: "${HDD_PATH}/appdata/immich/thumbs"
|
||||
id_from_name: "^([0-9a-f-]{36})_"
|
||||
repair:
|
||||
command: ["node", "-e", "x"]
|
||||
success: FELHOM_REPAIR_OK
|
||||
batch: 200
|
||||
report_lost:
|
||||
command: ["node", "-e", "y"]
|
||||
success: FELHOM_LOST_OK
|
||||
some_future_key: 1
|
||||
`
|
||||
var meta Metadata
|
||||
must(t, yaml.Unmarshal([]byte(src), &meta))
|
||||
s := meta.AfterUncleanBoot
|
||||
if s == nil || s.Service != "immich-server" || s.Repair.Batch != 200 || s.ReportLost == nil || s.EmptyFiles.IDFromName == "" {
|
||||
t.Fatalf("parsed %+v", s)
|
||||
}
|
||||
if _, ok := s.Extra["some_future_key"]; !ok || aubUnsupported(s) == "" {
|
||||
t.Fatalf("a newer key must be seen and refused: extra=%v", s.Extra)
|
||||
}
|
||||
delete(s.Extra, "some_future_key")
|
||||
if why := aubUnsupported(s); why != "" {
|
||||
t.Fatalf("the shipped shape must be supported: %s", why)
|
||||
}
|
||||
}
|
||||
|
||||
// The dismiss hides the household's note and keeps the counts.
|
||||
func TestAUB_DismissHidesTheNoteKeepsTheRecord(t *testing.T) {
|
||||
m, _, _, _ := aubManager(t)
|
||||
dir := filepath.Dir(m.stacks["cloudapp"].ComposePath)
|
||||
if err := m.DismissLostFiles("cloudapp"); err == nil {
|
||||
t.Fatal("nothing to dismiss must refuse")
|
||||
}
|
||||
m.mutateAppConfig("cloudapp", dir, "t", func(c *AppConfig) bool {
|
||||
c.AfterUncleanBoot = &AfterUncleanBootRecord{Found: 3, Lost: []LostFile{{Name: "a.jpg"}}}
|
||||
return true
|
||||
})
|
||||
must(t, m.DismissLostFiles("cloudapp"))
|
||||
r := LoadAppConfig(dir).AfterUncleanBoot
|
||||
if r == nil || !r.Dismissed || r.Found != 3 || len(r.Lost) != 1 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,8 @@ type AppConfig struct {
|
||||
DefaultLogin *DefaultLoginRecord `yaml:"default_login,omitempty" json:"default_login,omitempty"`
|
||||
// AfterSetup (v0.282.0) is what the template's after_setup (the app's own sign-up switch) last did.
|
||||
AfterSetup *AfterInstallRecord `yaml:"after_setup,omitempty" json:"after_setup,omitempty"`
|
||||
// AfterUncleanBoot (R-1007) is what the template's after_unclean_boot last found and did (after_unclean_boot.go).
|
||||
AfterUncleanBoot *AfterUncleanBootRecord `yaml:"after_unclean_boot,omitempty" json:"after_unclean_boot,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultLoginRecord is app.yaml's `default_login:`.
|
||||
|
||||
@@ -310,6 +310,10 @@ type Manager struct {
|
||||
updateUndoHealthFn func(ctx context.Context, name string, timeout time.Duration, meta *Metadata) (bool, string)
|
||||
// afterLoadFn is RunAfterLoad's exec seam (kept.go); nil = compose exec with the app's env.
|
||||
afterLoadFn func(dir string, args ...string) (string, error)
|
||||
// aubSleepFn is RunAfterUncleanBoot's wait seam (tests); nil = time.Sleep.
|
||||
aubSleepFn func(time.Duration)
|
||||
// aubRunningFn is its "is the app up" seam (tests); nil = RefreshStatus + StateRunning.
|
||||
aubRunningFn func(name string) bool
|
||||
// keptUnitFn names the removed app's recovery unit on a drive (backup.RemovedAppUnitFor, wired in
|
||||
// main.go) so a start-fresh moves it in with the files it belongs to. nil = files only.
|
||||
keptUnitFn func(app, drive string) string
|
||||
@@ -1150,6 +1154,11 @@ func deepCopyStack(s *Stack) Stack {
|
||||
acCopy.LockedFields = make([]string, len(s.AppConfig.LockedFields))
|
||||
copy(acCopy.LockedFields, s.AppConfig.LockedFields)
|
||||
}
|
||||
if r := s.AppConfig.AfterUncleanBoot; r != nil { // R-1007: the household's lost list is a slice
|
||||
rc := *r
|
||||
rc.Lost = append([]LostFile(nil), r.Lost...)
|
||||
acCopy.AfterUncleanBoot = &rc
|
||||
}
|
||||
cp.AppConfig = &acCopy
|
||||
}
|
||||
|
||||
@@ -1242,6 +1251,24 @@ func deepCopyStack(s *Stack) Stack {
|
||||
as.Args, as.Command, as.OpenCommand = cloneStrs(as.Args), cloneStrs(as.Command), cloneStrs(as.OpenCommand)
|
||||
cp.Meta.AfterSetup = &as
|
||||
}
|
||||
if s.Meta.AfterUncleanBoot != nil { // R-1007
|
||||
ab := *s.Meta.AfterUncleanBoot
|
||||
ab.Images = cloneStrs(ab.Images)
|
||||
ab.Repair.Command = cloneStrs(ab.Repair.Command)
|
||||
if ab.ReportLost != nil {
|
||||
rl := *ab.ReportLost
|
||||
rl.Command = cloneStrs(rl.Command)
|
||||
ab.ReportLost = &rl
|
||||
}
|
||||
if ab.Extra != nil {
|
||||
ex := make(map[string]any, len(ab.Extra))
|
||||
for k, v := range ab.Extra {
|
||||
ex[k] = v
|
||||
}
|
||||
ab.Extra = ex
|
||||
}
|
||||
cp.Meta.AfterUncleanBoot = &ab
|
||||
}
|
||||
if s.Meta.SetupDoneProbe != nil {
|
||||
sp := *s.Meta.SetupDoneProbe
|
||||
cp.Meta.SetupDoneProbe = &sp
|
||||
|
||||
@@ -71,6 +71,9 @@ type Metadata struct {
|
||||
// (anchored at a segment boundary). See family_gate.go.
|
||||
FamilyGate bool `yaml:"family_gate,omitempty" json:"family_gate,omitempty"`
|
||||
FamilyGateExcept []string `yaml:"family_gate_except,omitempty" json:"family_gate_except,omitempty"`
|
||||
// AfterUncleanBoot (R-1007, `09` §3 decision 236): after an unclean host boot, the app rebuilds the files a power
|
||||
// cut left empty, and its own lost-file check is read for the household's note. See after_unclean_boot.go.
|
||||
AfterUncleanBoot *AfterUncleanBootSpec `yaml:"after_unclean_boot,omitempty" json:"after_unclean_boot,omitempty"`
|
||||
// MinController (v0.287.0): the lowest box software that installs this template correctly; a lower one refuses.
|
||||
MinController string `yaml:"min_controller,omitempty" json:"min_controller,omitempty"`
|
||||
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
|
||||
|
||||
@@ -774,6 +774,11 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
// v0.279.0 (decision 45): the default-login card only while that login is in effect.
|
||||
data["KnownLoginLine"] = s.knownLoginLine(s.langFor(r), &found.Meta, found.AppConfig, found.Deployed)
|
||||
data["DefaultLoginReplaced"] = defaultLoginReplaced(&found.Meta, found.AppConfig, found.Deployed)
|
||||
// R-1007 (decision 236): the files the app's own check reported LOST after an unclean boot — the household can
|
||||
// upload them again from the phone. Shown until dismissed; repaired previews need no note.
|
||||
if lost, at := lostFilesNote(found.Deployed, found.AppConfig); len(lost) > 0 {
|
||||
data["LostFiles"], data["LostBootAt"] = lost, at
|
||||
}
|
||||
// v0.280.0 (decision 46): the setup gate's card, while the gate stands.
|
||||
data["SetupGateClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.SetupGate.Closed()
|
||||
data["SetupGateHasProbe"] = found.Meta.SetupDoneProbe != nil && found.Meta.SetupDoneProbe.URL != ""
|
||||
|
||||
@@ -413,6 +413,13 @@ func i18nCases() []i18nCase {
|
||||
d["SignupNativeOpenFailed"] = true // R-717: the window could not open the app's own switch
|
||||
return d
|
||||
}})
|
||||
// R-1007: the lost-files note after an unclean boot (name, date taken, the boot time, the dismiss button).
|
||||
base = append(base, i18nCase{"app_info_lost_files", "app_info", func() map[string]interface{} {
|
||||
d := signupCase("x", false, "").data()
|
||||
d["LostFiles"] = []stacks.LostFile{{Name: "IMG_0001.jpg", Taken: "2026-10-14 10:41"}}
|
||||
d["LostBootAt"] = "2026-10-14 14:07"
|
||||
return d
|
||||
}})
|
||||
// R-718: the close card and the gate card say the app restarts once where the app has its own switch.
|
||||
base = append(base, i18nCase{"app_info_close_signup_native", "app_info", func() map[string]interface{} {
|
||||
d := signupCase("x", false, "").data()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-1007 (decision 236) — the household sees the LOST files only (name and date taken), until it dismisses the note;
|
||||
// repaired previews never make a note. RED-PROOF: drop `|| rec.Dismissed` in lostFilesNote → the dismissed case
|
||||
// still shows → fails. RED-PROOF: drop the `.LostFiles` card from app_info.html → the render case fails.
|
||||
func TestLostFilesNote_OnlyTheLostOnesUntilDismissed(t *testing.T) {
|
||||
rec := &stacks.AfterUncleanBootRecord{BootAt: "2026-10-14T12:07:26Z", Found: 81, Repaired: 81,
|
||||
Lost: []stacks.LostFile{{Name: "IMG_0042.jpg", Taken: "2026-10-14 10:41"}}}
|
||||
lost, at := lostFilesNote(true, &stacks.AppConfig{AfterUncleanBoot: rec})
|
||||
if len(lost) != 1 || lost[0].Name != "IMG_0042.jpg" || at != "2026-10-14 14:07" {
|
||||
t.Fatalf("lost=%v at=%q", lost, at)
|
||||
}
|
||||
repairedOnly := &stacks.AfterUncleanBootRecord{BootAt: rec.BootAt, Found: 81, Repaired: 81}
|
||||
if l, _ := lostFilesNote(true, &stacks.AppConfig{AfterUncleanBoot: repairedOnly}); len(l) != 0 {
|
||||
t.Fatal("repaired previews made a note")
|
||||
}
|
||||
dismissed := *rec
|
||||
dismissed.Dismissed = true
|
||||
if l, _ := lostFilesNote(true, &stacks.AppConfig{AfterUncleanBoot: &dismissed}); len(l) != 0 {
|
||||
t.Fatal("a dismissed note is still shown")
|
||||
}
|
||||
if l, _ := lostFilesNote(false, &stacks.AppConfig{AfterUncleanBoot: rec}); len(l) != 0 {
|
||||
t.Fatal("a removed app shows a note")
|
||||
}
|
||||
|
||||
page := renderAppInfoWith(t, map[string]interface{}{"LostFiles": lost, "LostBootAt": at})
|
||||
for _, want := range []string{`id="lost-files-card"`, "IMG_0042.jpg", "2026-10-14 10:41", "2026-10-14 14:07", "/apps/gapp/lost-files/dismiss", "toltsd fel", "function appPress"} {
|
||||
if !strings.Contains(asciiFold(page), asciiFold(want)) {
|
||||
t.Fatalf("the card lacks %q", want)
|
||||
}
|
||||
}
|
||||
if plain := renderAppInfoWith(t, nil); strings.Contains(plain, `id="lost-files-card"`) {
|
||||
t.Fatal("a card with nothing lost")
|
||||
}
|
||||
}
|
||||
|
||||
// asciiFold drops the Hungarian accents so the test searches ASCII fragments (the project's rule for Hungarian text).
|
||||
func asciiFold(s string) string {
|
||||
return strings.NewReplacer("á", "a", "é", "e", "í", "i", "ó", "o", "ö", "o", "ő", "o", "ú", "u", "ü", "u", "ű", "u").Replace(s)
|
||||
}
|
||||
@@ -852,6 +852,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// it" under a known default login (R-710). POST, so CsrfProtect covers them.
|
||||
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/setup-gate/open") && r.Method == http.MethodPost:
|
||||
s.appSetupGateOpenHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/setup-gate/open"))
|
||||
// R-1007: the household dismisses the lost-files note. POST, so CsrfProtect covers it.
|
||||
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/lost-files/dismiss") && r.Method == http.MethodPost:
|
||||
s.appLostFilesDismissHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/lost-files/dismiss"))
|
||||
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/close-signup") && r.Method == http.MethodPost:
|
||||
s.appCloseSignupHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/close-signup"))
|
||||
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/signup-window") && r.Method == http.MethodPost:
|
||||
|
||||
@@ -386,6 +386,39 @@ func (s *Server) ServeSignupClosed(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// lostFilesNote is the app page's lost-files note (R-1007): the files the app's own check reported lost after an
|
||||
// unclean boot, until the household dismisses it, and the boot time in the box's zone. Nothing for an app with no
|
||||
// lost file (repaired previews need no note). Pinned by TestLostFilesNote_*.
|
||||
func lostFilesNote(deployed bool, cfg *stacks.AppConfig) ([]stacks.LostFile, string) {
|
||||
if !deployed || cfg == nil || cfg.AfterUncleanBoot == nil {
|
||||
return nil, ""
|
||||
}
|
||||
rec := cfg.AfterUncleanBoot
|
||||
if len(rec.Lost) == 0 || rec.Dismissed {
|
||||
return nil, ""
|
||||
}
|
||||
at := ""
|
||||
if t, err := time.Parse(time.RFC3339, rec.BootAt); err == nil {
|
||||
at = t.In(getTimezone()).Format("2006-01-02 15:04")
|
||||
}
|
||||
return rec.Lost, at
|
||||
}
|
||||
|
||||
// appLostFilesDismissHandler hides the app page's lost-files note (R-1007). The record (and the operator's counts)
|
||||
// stays; only the household's note goes.
|
||||
func (s *Server) appLostFilesDismissHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
||||
found := s.stackBySlug(slug)
|
||||
if found == nil {
|
||||
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
|
||||
return
|
||||
}
|
||||
if err := s.stackMgr.DismissLostFiles(found.Name); err != nil {
|
||||
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.lost_files.nothing_to_dismiss"))
|
||||
return
|
||||
}
|
||||
escrowJSON(w, http.StatusOK, map[string]any{"dismissed": true}, "")
|
||||
}
|
||||
|
||||
// appCloseSignupHandler is decision 49's "close sign-up now" (POST /apps/<slug>/close-signup) for an app installed
|
||||
// before decision 47. It applies exactly what a fresh install gets after its setup; it never gates the app.
|
||||
func (s *Server) appCloseSignupHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
||||
|
||||
@@ -28,6 +28,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{- if .LostFiles}}
|
||||
<div class="app-info-card alert-warning" style="margin-top:1rem" id="lost-files-card">
|
||||
<h3>{{T "app_info.lost_files_title"}}</h3>
|
||||
<p>{{T "app_info.lost_files_text"}}{{if .LostBootAt}} <strong data-lost-boot-at>{{.LostBootAt}}</strong>{{end}}</p>
|
||||
<ul data-lost-files>
|
||||
{{- range .LostFiles}}
|
||||
<li><span class="mono">{{.Name}}</span>{{if .Taken}} — {{T "app_info.lost_files_taken"}} {{.Taken}}{{end}}</li>
|
||||
{{- end}}
|
||||
</ul>
|
||||
<p>{{T "app_info.lost_files_hint"}}</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/lost-files/dismiss')">{{T "app_info.lost_files_dismiss_btn"}}</button>
|
||||
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
|
||||
</div>
|
||||
{{- end}}
|
||||
|
||||
{{if .Stack.Updating}}
|
||||
<div class="alert alert-info" style="margin-top:1rem" data-update-phase="{{.Stack.UpdatePhase}}">{{updatePhaseText .Stack}}</div>
|
||||
{{else if and .Stack.HoldReason (eq .Stack.HoldKind "unhealthy_stop")}}
|
||||
@@ -350,7 +365,7 @@ function icCopyPw(btn) {
|
||||
{{end}}
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{- if or .SetupGateClosed .SignupClosed .CloseSignupOffered (and .Stack.Deployed .KnownLoginLine)}}
|
||||
{{- if or .SetupGateClosed .SignupClosed .CloseSignupOffered (and .Stack.Deployed .KnownLoginLine) .LostFiles}}
|
||||
<script>
|
||||
// v0.280.0: the household's presses on this page — "Done, I set it up" (decision 46), "I changed it" (R-710).
|
||||
function appPress(btn, url) {
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu" class="no-js">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Opengist — Felhom.eu</title>
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
|
||||
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
|
||||
<meta name="csrf-token" content="tok">
|
||||
<script>
|
||||
|
||||
|
||||
document.documentElement.className=document.documentElement.className.replace('no-js','js');
|
||||
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
|
||||
var fhEN=document.documentElement.lang==='en';
|
||||
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
|
||||
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
|
||||
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
|
||||
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function felhomConfirm(el,question,onYes){
|
||||
if(!el||el.dataset.fcOpen)return;
|
||||
el.dataset.fcOpen='1';
|
||||
var wrap=document.createElement('span');wrap.className='inline-confirm';
|
||||
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
|
||||
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
|
||||
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
|
||||
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
|
||||
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
|
||||
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
|
||||
no.addEventListener('click',close);
|
||||
yes.addEventListener('click',function(){close();onYes();});
|
||||
}
|
||||
document.addEventListener('click',function(e){
|
||||
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
|
||||
if(!btn)return;
|
||||
e.preventDefault();
|
||||
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
|
||||
var form=btn.closest('form');
|
||||
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
document.addEventListener('click',function(e){
|
||||
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
|
||||
if(!btn)return;
|
||||
var group=btn.closest('.nav-group');
|
||||
if(!group)return;
|
||||
var willOpen=!group.classList.contains('is-open');
|
||||
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
|
||||
g.classList.remove('is-open');
|
||||
var t=g.querySelector('.nav-group-toggle');
|
||||
if(t)t.setAttribute('aria-expanded','false');
|
||||
});
|
||||
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
|
||||
});
|
||||
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
|
||||
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
|
||||
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
|
||||
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
|
||||
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
|
||||
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
|
||||
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
|
||||
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
|
||||
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
|
||||
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
|
||||
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
|
||||
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
|
||||
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
|
||||
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
|
||||
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
|
||||
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
|
||||
</svg>
|
||||
<header class="mobile-topbar">
|
||||
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
|
||||
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
|
||||
<svg class="ico"><use href="#i-menu"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="nav-backdrop" hidden></div>
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
|
||||
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
|
||||
<li><a href="/stacks" class="active"><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>Tárhely<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/storage" class="">Meghajtók</a></li>
|
||||
<li><a href="/storage/network" class="">Hálózati tárhely</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>Biztonsági mentés<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/backups" class="">Áttekintés</a></li>
|
||||
<li><a href="/backups/remote" class="">Távoli mentés</a></li>
|
||||
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
|
||||
<li><a href="/backups/restore" class="">Visszaállítás</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>Megosztás<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/sharing" class="">Hálózati megosztás</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/monitoring" class=""><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
|
||||
<li><a href="/debug" class=""><svg class="ico"><use href="#i-wrench"/></svg>Hibakeresés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="nav-group-label">Beállítások</div>
|
||||
<ul class="nav-links nav-links-sub">
|
||||
<li><a href="/settings" class=""><svg class="ico"><use href="#i-settings"/></svg>Rendszer</a></li>
|
||||
<li><a href="/settings/notifications" class=""><svg class="ico"><use href="#i-bell"/></svg>Értesítések</a></li>
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="page-header">
|
||||
<div style="display:flex;align-items:center;gap:1rem">
|
||||
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
|
||||
<h2>Opengist</h2>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:.5rem">
|
||||
|
||||
<span class="stack-state-badge state-run">Fut</span>
|
||||
|
||||
|
||||
|
||||
|
||||
<a href="/stacks/opengist/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||
|
||||
<a href="/stacks/opengist/export" class="btn btn-sm btn-outline">Exportálás</a>
|
||||
<a href="/stacks/opengist/deploy" class="btn btn-sm btn-outline">Beállítások</a>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-info-card alert-warning" style="margin-top:1rem" id="lost-files-card">
|
||||
<h3>Elveszett fotók egy váratlan leállás miatt</h3>
|
||||
<p>A gép váratlanul leállt (például áramszünet miatt), és ezek a fájlok elvesztek. A gép újraindulásának ideje: <strong data-lost-boot-at>2026-10-14 14:07</strong></p>
|
||||
<ul data-lost-files>
|
||||
<li><span class="mono">IMG_0001.jpg</span> — készült: 2026-10-14 10:41</li>
|
||||
</ul>
|
||||
<p>Ha a telefonodon még megvannak, töltsd fel őket újra. Nézd meg most, mielőtt a telefon felszabadítja a helyüket.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/opengist/lost-files/dismiss')">Rendben, elolvastam</button>
|
||||
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-info-hero">
|
||||
<img class="app-info-logo" src="/static/assets/opengist-logo.svg"
|
||||
alt="Opengist" data-fallback="/static/app-placeholder.svg"
|
||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/opengist-logo.png';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
|
||||
<div class="app-info-hero-text">
|
||||
|
||||
<p class="app-info-tagline"></p>
|
||||
|
||||
<div class="stack-meta-badges">
|
||||
<span class="meta-badge">~ RAM</span>
|
||||
<span class="meta-badge"></span>
|
||||
|
||||
<span class="meta-badge meta-badge-warn">Csak x86</span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots" id="screenshots">
|
||||
<img src="/static/assets/opengist-screenshot-1.webp" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
<img src="/static/assets/opengist-screenshot-2.webp" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
<img src="/static/assets/opengist-screenshot-3.webp" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</main>
|
||||
<script>
|
||||
|
||||
|
||||
|
||||
(function(){
|
||||
var burger=document.querySelector('.nav-burger');
|
||||
var sidebar=document.getElementById('sidebar');
|
||||
var backdrop=document.querySelector('.nav-backdrop');
|
||||
if(!burger||!sidebar||!backdrop)return;
|
||||
function setOpen(open){
|
||||
sidebar.classList.toggle('is-open',open);
|
||||
document.body.classList.toggle('nav-open',open);
|
||||
backdrop.hidden=!open;
|
||||
burger.setAttribute('aria-expanded',open?'true':'false');
|
||||
}
|
||||
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
|
||||
backdrop.addEventListener('click',function(){setOpen(false);});
|
||||
document.addEventListener('keydown',function(e){
|
||||
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
|
||||
});
|
||||
})();
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
|
||||
var card = e.target.closest('[data-href]');
|
||||
if (card) window.location.href = card.dataset.href;
|
||||
});
|
||||
async function checkBeforeDeploy(e, name) {
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name);
|
||||
var data = await resp.json();
|
||||
if (data.ok && data.data && data.data.deployed) {
|
||||
e.preventDefault();
|
||||
showAlert('Ez az alkalmazás már telepítve van.');
|
||||
window.location.reload();
|
||||
return false;
|
||||
}
|
||||
} catch(err) {}
|
||||
return true;
|
||||
}
|
||||
async function syncTemplates() {
|
||||
const btn = document.getElementById('sync-btn');
|
||||
const toast = document.getElementById('sync-toast');
|
||||
if (!btn) return;
|
||||
const origText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '↻ Frissítés...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (toast) {
|
||||
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
if (toast) {
|
||||
toast.textContent = 'Hálózati hiba: ' + err.message;
|
||||
toast.className = 'sync-toast sync-toast-err';
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
}
|
||||
btn.innerHTML = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
async function stackAction(event, name, action) {
|
||||
const btn = event.currentTarget;
|
||||
const origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Folyamatban...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (action === 'update') {
|
||||
followUpdate(name, btn);
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
function followUpdate(name, btn) {
|
||||
var started = Date.now();
|
||||
var timer = setInterval(async function() {
|
||||
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
|
||||
try {
|
||||
var r = await fetch('/api/stacks/' + name);
|
||||
var d = await r.json();
|
||||
if (!d.ok || !d.data) return;
|
||||
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
|
||||
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
|
||||
} catch (e) {}
|
||||
}, 3000);
|
||||
}
|
||||
async function deleteOrphanStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'delete-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
|
||||
var data = await resp.json();
|
||||
var hddInfo = '';
|
||||
var checkboxHTML = '';
|
||||
if (data.ok && data.data && data.data.has_hdd_data) {
|
||||
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
|
||||
data.data.hdd_paths.forEach(function(p) {
|
||||
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
hddInfo += '</div>';
|
||||
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
|
||||
}
|
||||
|
||||
if (data.ok && data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
var udPaths = '';
|
||||
data.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
||||
hddInfo += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
hddInfo + checkboxHTML +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
|
||||
'</div>';
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeDeleteModal() {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmDelete(name) {
|
||||
var btn = document.getElementById('confirm-delete-btn');
|
||||
var checkbox = document.getElementById('delete-hdd-check');
|
||||
var removeHDD = checkbox ? checkbox.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Törlés folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name, {
|
||||
method: 'DELETE',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.hdd_note) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
||||
}
|
||||
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen törölve!</h3>' +
|
||||
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') törölve lett.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
}
|
||||
async function removeStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'remove-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var [hddResp, backupResp] = await Promise.all([
|
||||
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
|
||||
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
|
||||
'<li>Telepítési konfiguráció (app.yaml)</li>' +
|
||||
'<li>Másodlagos mentés ütemezése</li>' +
|
||||
'</ul></div>';
|
||||
|
||||
var hddCheckbox = '';
|
||||
if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
|
||||
var hddPaths = '';
|
||||
hddResp.data.hdd_paths.forEach(function(p) {
|
||||
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
|
||||
'Ha újratelepíted az alkalmazást, az adatokat újra importálnod kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
|
||||
'</div></div>';
|
||||
hddCheckbox = '\x3Cscript>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
|
||||
}
|
||||
|
||||
var backupCheckbox = '';
|
||||
if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) {
|
||||
var bkPaths = '';
|
||||
backupResp.data.backup_paths.forEach(function(p) {
|
||||
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
});
|
||||
if (bkPaths) {
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mentési adatok:</strong>' + bkPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
|
||||
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
|
||||
'</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (hddResp.ok && hddResp.data && hddResp.data.userdata_kept && hddResp.data.userdata_kept.length > 0) {
|
||||
var udPaths = '';
|
||||
hddResp.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
||||
sections += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
sections +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
|
||||
'</div>' + hddCheckbox;
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeRemoveModal() {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmRemoveStack(name) {
|
||||
var btn = document.getElementById('confirm-remove-btn');
|
||||
var hddCheck = document.getElementById('remove-hdd-check');
|
||||
var backupCheck = document.getElementById('remove-backup-check');
|
||||
var removeHDD = hddCheck ? hddCheck.checked : false;
|
||||
var removeBackups = backupCheck ? backupCheck.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Eltávolítás folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/remove', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.hdd_note) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
||||
}
|
||||
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen eltávolítva!</h3>' +
|
||||
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<script>
|
||||
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
btn.disabled = true;
|
||||
fetch(url, {method: 'POST', headers: csrfHeaders(), credentials: 'same-origin'})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
if (!j.ok) { throw new Error(j.error || ''); }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (e) { btn.disabled = false; if (err) { err.textContent = e.message; err.style.display = 'inline'; } });
|
||||
}
|
||||
</script>
|
||||
@@ -230,6 +230,9 @@
|
||||
"poweroff.confirm_required": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_ConfirmationWordRequired.",
|
||||
"poweroff.agent_down": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.",
|
||||
"poweroff.agent_outdated": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_HiddenForOldAgent.",
|
||||
"event.app_repaired_after_crash": "BORN AS A KEY (R-1007, v0.311.0) -- the operator's counts after an unclean boot (app_repaired_after_crash). Pinned by TestAppRepairedAfterCrash_KeysInBothLanguages.",
|
||||
"event.app_repair_after_crash_skipped": "BORN AS A KEY (R-1007, v0.311.0) -- the same event when the run was skipped, with its reason. Pinned by TestAppRepairedAfterCrash_KeysInBothLanguages.",
|
||||
"err.lost_files.nothing_to_dismiss": "BORN AS A KEY (R-1007, v0.311.0) -- the dismiss route's answer when there is no lost-files note. Pinned by TestAppRepairedAfterCrash_KeysInBothLanguages.",
|
||||
"poweroff.refused.too_soon": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.",
|
||||
"poweroff.refused.already": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.",
|
||||
"poweroff.refused.backup": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.",
|
||||
|
||||
Reference in New Issue
Block a user