D65 = A: the busy file the agent reads before an operator reboot, and the household's 'Felhom support restarted your box' line; R-1350: a hold clear records the wanted stop so no app_start_failed follows it
gates / gates (push) Successful in 32s
gates / gates (push) Successful in 32s
This commit is contained in:
1 parent
a28db2d17f
commit
b69e90bdad
22 files changed
+662
-11
No files matched your search
@@ -1,3 +1,28 @@
|
||||
## Unreleased — the household line after an operator reboot and the busy file for it (D65 = A); a hold clear records the wanted stop (R-1350)
|
||||
|
||||
### 2026-10-29 (evening)
|
||||
|
||||
**MinAgent: 0.158.1** (unchanged). The new `GET /guest/operator-reboot` is asked once at start; an older agent answers
|
||||
404, and such an agent cannot reboot from the hub either — nothing to show, nothing broken. The busy file is read BY the
|
||||
agent (≥ the reboot_guest commit); an older agent never reads it.
|
||||
|
||||
- **D65 = A — the busy file (`internal/busymark`).** `<data dir>/ctl/busy`, rewritten every 15 s while a backup, a
|
||||
restore, an off-site run, the night catch-up, an app update or an app export/import runs; removed when idle and at
|
||||
start. The agent refuses the operator's „Reboot guest" while it is fresh. Tests `TestBusyFileFollowsTheWork`,
|
||||
`TestBusyMarkPathIsTheOneTheAgentReads` (the path is the agent's guest path), `TestBusyMarkIsWiredAtStartup`.
|
||||
- **D65 = A — „Felhom support restarted your box at <time>."** The dashboard's one neutral line for a day after an
|
||||
operator reboot, HU/EN (`dashboard.operator_reboot`); the idle dashboard is byte-identical (the parity fixtures pass
|
||||
unchanged). Tests `TestOperatorRebootNotice_*`, `TestWatchOperatorReboot_RetriesAndKeepsTheTime`.
|
||||
- **R-1350 — no „installed app is not running" after the operator's hold clear.** Measured 2026-10-29 14:57 on Tester 1:
|
||||
gokapi's crash-loop hold cleared from the hub, the app stayed stopped, and 30 s later `app_start_failed` went out —
|
||||
the hold had kept it out of the dead-app scan while the household's recorded wish was still „running". The clear now
|
||||
records the stop as wanted (desired state „stopped") for an app that is not up, once the hold is found and before it
|
||||
is lifted; a running app's wish is left alone; a refused clear records nothing; the household's Start ends it.
|
||||
Tests `TestR1350_*` (the consequence through `classifyRunStates`, the order against the lift, the refusal, the up
|
||||
check, the wiring).
|
||||
- Every new test seen failing by a mutation (17 mutants; two first-run mutants that did not compile were re-made so they
|
||||
compile, and then failed).
|
||||
|
||||
## v0.348.0 (2026-10-29) — clear_hold from the hub (D64 = B); the box command clears through the running controller (R-1343); the held-app mail and line name the real cause (R-1340); the OOM line once per kill (R-1341); the restore copy safe during the stable-start watch (R-1342) — built from the commit carrying this heading
|
||||
|
||||
### 2026-10-29 (afternoon)
|
||||
|
||||
@@ -3663,6 +3663,19 @@ starts a countdown or shortens one (`TestOpActions_ClosedList`). The box's log r
|
||||
dropped from the app-stop marker too; its copy stays on disk. The update's in-memory „held" flag
|
||||
(`stacks.Manager.ForgetUpdateHeld`) goes with it; the hold reporter's next tick (≤1 min) sends `app_hold_cleared`.
|
||||
Refused, nothing changed: a bad name, or an app with no hold. No new event type: the hub records the result.
|
||||
**R-1350 (2026-10-29):** for an app that is not up, the clear records the stop as WANTED (desired state „stopped",
|
||||
`stacks.Manager.RecordStopWantedIfDown`) once the hold is found and BEFORE it is lifted
|
||||
(`AppStopGuard.ClearHoldByOperatorThen`), so the dead-app scan sends no „Telepített alkalmazás nem fut" for it and the
|
||||
boot reconciler does not start it; the household's Start records „running" and ends it.
|
||||
- **The busy file for the host agent (D65 = A, 2026-10-29; `internal/busymark`).** While a backup, a restore, an
|
||||
off-site run, the night catch-up, an app update or an app export/import runs, the controller rewrites
|
||||
`<data dir>/ctl/busy` every 15 s (line 1 the UTC time, line 2 what runs, 0600) and removes it when idle. The agent reads
|
||||
it before the operator's „Reboot guest" and refuses while it is fresh (≤ 90 s); a controller that stopped writing it
|
||||
does not block the reboot.
|
||||
- **„Felhom support restarted your box" (D65 = A).** At start the controller asks the agent `GET /guest/operator-reboot`
|
||||
(retrying for up to 10 minutes; a 404 from an older agent ends it) and the dashboard shows one neutral line,
|
||||
„A Felhom ügyfélszolgálata HH:MM-kor újraindította a dobozt." / „Felhom support restarted your box at HH:MM.", until a
|
||||
day has passed since the reboot.
|
||||
- **The box command `--clear-restore-hold <app>`** (`pct exec <vmid> -- docker exec felhom-controller felhom-controller
|
||||
--clear-restore-hold <app>`) asks the running controller over a unix socket, `<data dir>/ctl/hold.sock` (0600 in a
|
||||
0700 dir, peer uid checked, nothing on TCP — not reachable through traefik, the LAN or an app), and says no restart is
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/busymark"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// D65 = A: the agent reads ONE fixed guest path — the controller-data volume + data/ctl/busy
|
||||
// (felhom-agent ControllerBusyMarkerGuestPath, and its sudoers line). The volume is mounted at
|
||||
// /opt/docker/felhom-controller, so the controller must write <default data dir>/ctl/busy.
|
||||
// RED-PROOF: rename holdCtlDir or busymark.FileName → the path moves → FAILS.
|
||||
func TestBusyMarkPathIsTheOneTheAgentReads(t *testing.T) {
|
||||
cfg, err := config.LoadFromBytes([]byte("customer:\n id: t\n domain: t.example\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := filepath.Join(cfg.Paths.DataDir, holdCtlDir, busymark.FileName)
|
||||
want := "/opt/docker/felhom-controller/data/ctl/busy" // = the agent's /var/lib/docker/volumes/felhom-controller-data/_data/data/ctl/busy
|
||||
if got != want {
|
||||
t.Fatalf("the busy file is %s, the agent reads %s — an operator reboot would never be refused for a backup", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Every kind of work has its probe and the writer is started — a seam built and never wired is this project's commonest
|
||||
// defect. RED-PROOF: delete any AddProbe line or the Run → FAILS.
|
||||
func TestBusyMarkIsWiredAtStartup(t *testing.T) {
|
||||
raw, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(raw)
|
||||
for _, want := range []string{
|
||||
`busyMark.AddProbe("an app update is running", stackMgr.AnyUpdating)`,
|
||||
`return backupMgr.IsRunning() || backupMgr.RestoreStatus().Running`,
|
||||
`busyMark.AddProbe("the night backup catch-up is running", catchUp.Running)`,
|
||||
`busyMark.AddProbe("an app export or import is running", appExporter.IsRunning)`,
|
||||
`go busyMark.Run(ctx)`,
|
||||
`go webServer.WatchOperatorReboot(ctx)`, // the household line after an operator reboot
|
||||
} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Fatalf("main.go does not contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,12 @@ import (
|
||||
|
||||
// holdClearDeps are clearHoldNow's seams (production: holdClearDepsFor).
|
||||
type holdClearDeps struct {
|
||||
clear func(stack string) (settings.RestoreHold, error) // backup.AppStopGuard.ClearHoldByOperator
|
||||
forgetUpdateHeld func(stack string) bool // stacks.Manager.ForgetUpdateHeld (nil = none)
|
||||
logger *log.Logger
|
||||
clear func(stack string, beforeLift func()) (settings.RestoreHold, error) // backup.AppStopGuard.ClearHoldByOperatorThen
|
||||
forgetUpdateHeld func(stack string) bool // stacks.Manager.ForgetUpdateHeld (nil = none)
|
||||
// keepStopped (R-1350) records that the stop is wanted for an app that is not up — run once the hold is found and
|
||||
// BEFORE it is lifted: stacks.Manager.RecordStopWantedIfDown (nil = none).
|
||||
keepStopped func(stack string) (bool, error)
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// clearHoldNow lifts stack's hold and returns the result sentence (English, operator-facing). who names the person
|
||||
@@ -43,7 +46,19 @@ func clearHoldNow(d holdClearDeps, stack, who string) (string, error) {
|
||||
if d.clear == nil {
|
||||
return "", fmt.Errorf("%w: clearing a hold is not available on this box", report.ErrActionRefused)
|
||||
}
|
||||
h, err := d.clear(stack)
|
||||
var before func()
|
||||
if d.keepStopped != nil {
|
||||
before = func() {
|
||||
rec, kerr := d.keepStopped(stack)
|
||||
switch {
|
||||
case kerr != nil && d.logger != nil:
|
||||
d.logger.Printf("[WARN] [hold] %s: the stop could not be recorded as wanted (%v) — the dead-app alarm may follow the clear (R-1350)", stack, kerr)
|
||||
case rec && d.logger != nil:
|
||||
d.logger.Printf("[INFO] [hold] %s: the stop is recorded as wanted (desired state: stopped) — no dead-app alarm; the household's Start ends it (R-1350)", stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
h, err := d.clear(stack, before)
|
||||
switch {
|
||||
case errors.Is(err, backup.ErrHoldBadStack):
|
||||
return "", fmt.Errorf("%w: %q is not a valid app name — nothing was changed", report.ErrActionRefused, stack)
|
||||
|
||||
@@ -76,7 +76,7 @@ func newD64Rig(t *testing.T) *d64Rig {
|
||||
r.guard = backup.NewAppStopGuard(filepath.Join(r.dir, "appstop-state.json"), r.logger)
|
||||
r.guard.SetHolds(sett)
|
||||
r.holdDeps = holdClearDeps{
|
||||
clear: r.guard.ClearHoldByOperator,
|
||||
clear: r.guard.ClearHoldByOperatorThen,
|
||||
forgetUpdateHeld: func(s string) bool { r.forgot = append(r.forgot, s); return true },
|
||||
logger: r.logger,
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/bootrecon"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/bootstrap"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/busymark"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/channelhealth"
|
||||
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
@@ -467,7 +468,8 @@ func main() {
|
||||
// process so memory and settings.json agree (holdclear.go). The box command reaches it over a unix socket in the
|
||||
// data dir (0600, nothing on TCP). A socket that cannot be bound is logged; the command then says plainly that no
|
||||
// running controller answered.
|
||||
holdDeps := holdClearDeps{clear: appStopGuard.ClearHoldByOperator, forgetUpdateHeld: stackMgr.ForgetUpdateHeld, logger: logger}
|
||||
holdDeps := holdClearDeps{clear: appStopGuard.ClearHoldByOperatorThen, forgetUpdateHeld: stackMgr.ForgetUpdateHeld,
|
||||
keepStopped: stackMgr.RecordStopWantedIfDown, logger: logger}
|
||||
if holdLn, herr := listenHoldCtl(holdCtlPath(cfg.Paths.DataDir), func(stack, who string) (string, error) {
|
||||
return clearHoldNow(holdDeps, stack, who)
|
||||
}, logger); herr != nil {
|
||||
@@ -577,6 +579,11 @@ func main() {
|
||||
logger.Println("[INFO] Healthchecks ping UUIDs configured but no longer used — monitoring is now handled by the Hub")
|
||||
}
|
||||
|
||||
// D65 = A (2026-10-29): the busy file the host agent reads before an operator reboot of the guest (internal/busymark).
|
||||
// Probes are added where each component is built; the writer starts once the last one exists (after the exporter).
|
||||
busyMark := &busymark.Writer{Path: filepath.Join(cfg.Paths.DataDir, holdCtlDir, busymark.FileName), Logger: logger}
|
||||
busyMark.AddProbe("an app update is running", stackMgr.AnyUpdating)
|
||||
|
||||
// --- Initialize backup manager (app-data only: DB dumps + Docker-volume tars) ---
|
||||
var backupMgr *backup.Manager
|
||||
// R-550: the restore the last stop interrupted, found when the record loads; raised once the notifier exists.
|
||||
@@ -588,6 +595,9 @@ func main() {
|
||||
}
|
||||
if cfg.Backup.Enabled {
|
||||
backupMgr = backup.NewManager(cfg, sett, logger)
|
||||
busyMark.AddProbe("a backup, a restore or an off-site run is running", func() bool {
|
||||
return backupMgr.IsRunning() || backupMgr.RestoreStatus().Running
|
||||
})
|
||||
// The stray-copy fix: a second-drive copy container a previous controller process lost (restart, self-update)
|
||||
// still runs `rsync --delete`. Remove every one BEFORE the scheduler or the web server can start a backup or a
|
||||
// restore; until that succeeds, restores are refused. Pinned by TestStrayCopy_ClearIsWiredBeforeTheScheduler.
|
||||
@@ -1661,6 +1671,7 @@ func main() {
|
||||
if quiesceLoop != nil {
|
||||
quiesceLoop.SetCatchUpFn(catchUp.Running)
|
||||
}
|
||||
busyMark.AddProbe("the night backup catch-up is running", catchUp.Running)
|
||||
catchUp.Evaluate(ctx, "controller start")
|
||||
resumeTick := time.NewTicker(time.Minute)
|
||||
go nightchain.ResumeWatch(ctx, resumeTick.C,
|
||||
@@ -2119,6 +2130,8 @@ func main() {
|
||||
// this the export path would be the uncovered sibling of two covered ones, which is how a reader
|
||||
// concludes the whole class is handled (§2.2).
|
||||
appExporter.SetStopGuard(exportStopGuard{g: appStopGuard})
|
||||
busyMark.AddProbe("an app export or import is running", appExporter.IsRunning)
|
||||
go busyMark.Run(ctx)
|
||||
// R-1279 (D48 for the import): the import downloads first, with the restores' helper and sentences.
|
||||
if backupMgr != nil {
|
||||
appExporter.SetImagePreparer(backupMgr.PrepareRestoreImages)
|
||||
@@ -2127,6 +2140,7 @@ func main() {
|
||||
|
||||
// --- Initialize web server ---
|
||||
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, alertMgr, notifier, updater, logger, Version)
|
||||
go webServer.WatchOperatorReboot(ctx) // D65: „Felhom support restarted your box at …" after an operator reboot
|
||||
// v0.316.0: which BUILD this is, not just which version it calls itself. Every shell's stylesheet
|
||||
// URL is derived from it, so two builds of one version can never share a cache entry. Init-only,
|
||||
// before the HTTP server starts.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-1350 (measured 2026-10-29 14:57 on Tester 1): the operator cleared gokapi's crash-loop hold from the hub, the app
|
||||
// stayed stopped (the clear starts nothing), and 30 s later the dead-app scan sent „Telepített alkalmazás nem fut:
|
||||
// Gokapi" — the hold had kept the app out of the scan, and the household's recorded wish was still „running".
|
||||
// The fix: the clear records that the stop is WANTED (desired state „stopped") for an app that is not up, BEFORE the
|
||||
// hold is lifted; a later Start records „running" and ends it.
|
||||
|
||||
// The consequence, through the scan's own derivation: a stopped app whose wish is „running" is DOWN (the alarm); the
|
||||
// same app with the wish „stopped" is not.
|
||||
func TestR1350_AStopWantedIsNotDown(t *testing.T) {
|
||||
st := func(desired string) []stacks.Stack {
|
||||
return []stacks.Stack{{Name: "gokapi", Deployed: true, State: stacks.StateStopped, AppConfig: &stacks.AppConfig{DesiredState: desired}}}
|
||||
}
|
||||
if _, states := classifyRunStates(st(stacks.DesiredStateRunning), nil, nil, time.Now()); len(states) != 1 || !states[0].Down {
|
||||
t.Fatalf("fixture: a stopped app wished running must be down: %+v", states)
|
||||
}
|
||||
if _, states := classifyRunStates(st(stacks.DesiredStateStopped), nil, nil, time.Now()); len(states) != 1 || states[0].Down {
|
||||
t.Fatalf("a stopped app wished stopped is down — the alarm fires: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
// The clear records the wish BEFORE the hold is lifted (a dead-app scan between the two would read „no hold, wished
|
||||
// running" and mail), and only for an app that IS held. RED-PROOF: drop the keepStopped call from the clear → FAILS
|
||||
// („not recorded"); call it after the lift → FAILS („after the hold was lifted").
|
||||
func TestR1350_TheClearRecordsTheStopIsWanted(t *testing.T) {
|
||||
r := newD64Rig(t)
|
||||
r.hold(t, settings.RestoreHold{Stack: "gokapi", Reason: settings.HoldReasonUnhealthyStop, UnhealthyKind: "crash_loop", Trip: 1})
|
||||
var recorded []string
|
||||
var heldWhenRecorded bool
|
||||
r.holdDeps.keepStopped = func(stack string) (bool, error) {
|
||||
recorded = append(recorded, stack)
|
||||
_, heldWhenRecorded = r.sett.GetRestoreHold(stack)
|
||||
return true, nil
|
||||
}
|
||||
o := r.actions()
|
||||
o.Reconcile([]report.OperatorAction{{ID: 21, Action: report.OpClearHold, Arg: "gokapi"}})
|
||||
if res := onlyResult(t, o, 21); res.Outcome != report.OutcomeDone {
|
||||
t.Fatalf("result %+v", res)
|
||||
}
|
||||
if len(recorded) != 1 || recorded[0] != "gokapi" {
|
||||
t.Fatalf("the stop was not recorded as wanted: %v", recorded)
|
||||
}
|
||||
if !heldWhenRecorded {
|
||||
t.Fatal("the wish was recorded after the hold was lifted — a scan in between mails app_start_failed")
|
||||
}
|
||||
if lg := r.logBuf.String(); !strings.Contains(lg, "gokapi: the stop is recorded as wanted") {
|
||||
t.Fatalf("the box's log must say it:\n%s", lg)
|
||||
}
|
||||
// a refusal (no hold) records nothing — a down app with no hold is a real fault the alarm must keep
|
||||
o.Reconcile([]report.OperatorAction{{ID: 22, Action: report.OpClearHold, Arg: "kimai"}})
|
||||
if res := onlyResult(t, o, 22); res.Outcome != report.OutcomeRefused {
|
||||
t.Fatalf("result %+v", res)
|
||||
}
|
||||
if len(recorded) != 1 {
|
||||
t.Fatalf("a refused clear recorded a wish: %v", recorded)
|
||||
}
|
||||
}
|
||||
|
||||
// The running controller's clear is wired with the record. RED-PROOF: drop keepStopped from main.go's holdDeps → FAILS.
|
||||
func TestR1350_MainWiresTheRecord(t *testing.T) {
|
||||
raw, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "keepStopped: stackMgr.RecordStopWantedIfDown") || !strings.Contains(string(raw), "clear: appStopGuard.ClearHoldByOperatorThen") {
|
||||
t.Fatal("main.go's hold clear does not record the wanted stop (R-1350)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OperatorReboot calls GET /guest/operator-reboot (D65 = A, agent ≥ the reboot_guest commit): the time the operator
|
||||
// rebooted THIS guest from the hub, when it is less than a day old; the zero time when there is none. An agent that
|
||||
// predates the route answers 404 (a *StatusError) — such an agent cannot reboot from the hub either, so there is
|
||||
// nothing to show and no MinAgent is needed.
|
||||
func (c *Client) OperatorReboot(ctx context.Context) (time.Time, error) {
|
||||
body, err := c.get(ctx, "/guest/operator-reboot")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
var out struct {
|
||||
At string `json:"at"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return time.Time{}, fmt.Errorf("agentapi: decode /guest/operator-reboot: %w", err)
|
||||
}
|
||||
if out.At == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
at, err := time.Parse(time.RFC3339, out.At)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("agentapi: /guest/operator-reboot: bad time %q", out.At)
|
||||
}
|
||||
return at, nil
|
||||
}
|
||||
@@ -39,6 +39,13 @@ func IsValidHoldStack(s string) bool { return isSafeStackName(s) }
|
||||
// returns the hold it lifted. A waiting put-back is dropped from the app-stop marker too (see above). Refusals
|
||||
// (ErrHoldBadStack, ErrNotHeld) change nothing.
|
||||
func (g *AppStopGuard) ClearHoldByOperator(stack string) (settings.RestoreHold, error) {
|
||||
return g.ClearHoldByOperatorThen(stack, nil)
|
||||
}
|
||||
|
||||
// ClearHoldByOperatorThen is ClearHoldByOperator with beforeLift run once the hold is FOUND and before it is lifted,
|
||||
// under the same lock (R-1350: the household's „the stop is wanted" is recorded before the hold stops keeping the app out
|
||||
// of the dead-app scan). beforeLift is not run on a refusal.
|
||||
func (g *AppStopGuard) ClearHoldByOperatorThen(stack string, beforeLift func()) (settings.RestoreHold, error) {
|
||||
if !isSafeStackName(stack) {
|
||||
return settings.RestoreHold{}, ErrHoldBadStack
|
||||
}
|
||||
@@ -51,6 +58,9 @@ func (g *AppStopGuard) ClearHoldByOperator(stack string) (settings.RestoreHold,
|
||||
if !ok {
|
||||
return settings.RestoreHold{}, ErrNotHeld
|
||||
}
|
||||
if beforeLift != nil {
|
||||
beforeLift()
|
||||
}
|
||||
cleared, err := g.holds.ClearRestoreHold(stack)
|
||||
if err != nil {
|
||||
return h, err
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package busymark writes the controller's „busy" file for the host agent (D65 = A, operator ruling 2026-10-29 15:50;
|
||||
// felhom.eu `03`, `04` §6.1).
|
||||
//
|
||||
// WHY: the operator can reboot the guest from the hub. The agent refuses while a backup, a restore, an off-site run or
|
||||
// an app update runs — but the controller's own work is invisible to the agent (it runs inside this container). So
|
||||
// while any of it runs, this package keeps `<data>/ctl/busy` fresh: line 1 the time (RFC 3339, UTC), line 2 what runs.
|
||||
// The agent reads it over one exact read-only grant (`pct exec <vmid> -- cat <the guest path>`), without Docker.
|
||||
//
|
||||
// FRESHNESS IS THE SIGNAL, NOT PRESENCE: the file is rewritten every Every (15 s) while busy and removed when idle.
|
||||
// A controller that is killed or stuck mid-backup stops rewriting it, and the agent (90 s) reads the old file as „not
|
||||
// busy" — a stuck controller is exactly the box a reboot is for. A file left by a previous run is removed at start.
|
||||
// Pinned by busymark_test.go.
|
||||
package busymark
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileName is the busy file inside the ctl directory (the directory of the hold-clear socket, 0700).
|
||||
const FileName = "busy"
|
||||
|
||||
// DefaultEvery is the rewrite cadence while busy.
|
||||
const DefaultEvery = 15 * time.Second
|
||||
|
||||
// Writer keeps the file in step with its probes.
|
||||
type Writer struct {
|
||||
Path string // <data>/ctl/busy
|
||||
Every time.Duration
|
||||
Now func() time.Time
|
||||
Logger *log.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
probes []probe
|
||||
last string // what the last tick saw ("" = idle)
|
||||
}
|
||||
|
||||
type probe struct {
|
||||
what string
|
||||
busy func() bool
|
||||
}
|
||||
|
||||
// AddProbe adds one kind of work: what is the sentence the agent shows the operator, busy says whether it runs now.
|
||||
// A nil busy is ignored (a box without that component).
|
||||
func (w *Writer) AddProbe(what string, busy func() bool) {
|
||||
if busy == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.probes = append(w.probes, probe{what: what, busy: busy})
|
||||
}
|
||||
|
||||
// Busy returns the first running work's sentence, or "" when nothing runs.
|
||||
func (w *Writer) Busy() string {
|
||||
w.mu.Lock()
|
||||
ps := append([]probe(nil), w.probes...)
|
||||
w.mu.Unlock()
|
||||
for _, p := range ps {
|
||||
if p.busy() {
|
||||
return p.what
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (w *Writer) now() time.Time {
|
||||
if w.Now != nil {
|
||||
return w.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (w *Writer) logf(format string, args ...any) {
|
||||
if w.Logger != nil {
|
||||
w.Logger.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Tick runs one evaluation: write the file when busy, remove it when idle. Exported for the test and the start.
|
||||
func (w *Writer) Tick() {
|
||||
what := w.Busy()
|
||||
if what == "" {
|
||||
if err := os.Remove(w.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
w.logf("[WARN] [busymark] the busy file could not be removed: %v — the agent reads it as busy for up to 90 s more", err)
|
||||
}
|
||||
if w.last != "" {
|
||||
w.logf("[INFO] [busymark] idle again (was: %s) — an operator reboot of the box is no longer refused for it", w.last)
|
||||
}
|
||||
w.last = ""
|
||||
return
|
||||
}
|
||||
body := w.now().UTC().Format(time.RFC3339) + "\n" + what + "\n"
|
||||
if err := os.MkdirAll(filepath.Dir(w.Path), 0o700); err == nil {
|
||||
tmp := w.Path + ".tmp"
|
||||
if err = os.WriteFile(tmp, []byte(body), 0o600); err == nil {
|
||||
err = os.Rename(tmp, w.Path)
|
||||
}
|
||||
if err != nil {
|
||||
w.logf("[WARN] [busymark] the busy file could not be written: %v — an operator reboot is not refused for %s", err, what)
|
||||
}
|
||||
}
|
||||
if w.last != what {
|
||||
w.logf("[INFO] [busymark] busy: %s — an operator reboot of the box is refused until it ends", what)
|
||||
}
|
||||
w.last = what
|
||||
}
|
||||
|
||||
// Run removes a file left by an earlier run, then ticks every Every until ctx ends (and removes the file then).
|
||||
func (w *Writer) Run(ctx context.Context) {
|
||||
every := w.Every
|
||||
if every <= 0 {
|
||||
every = DefaultEvery
|
||||
}
|
||||
w.Tick()
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = os.Remove(w.Path)
|
||||
return
|
||||
case <-t.C:
|
||||
w.Tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package busymark
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// D65 = A: the agent refuses an operator reboot while the controller's busy file is fresh.
|
||||
|
||||
func newW(t *testing.T) (*Writer, *bool, *time.Time) {
|
||||
t.Helper()
|
||||
busy := false
|
||||
now := time.Date(2026, 10, 29, 16, 0, 0, 0, time.UTC)
|
||||
w := &Writer{Path: filepath.Join(t.TempDir(), "ctl", FileName), Now: func() time.Time { return now }}
|
||||
w.AddProbe("a backup or a restore is running", func() bool { return busy })
|
||||
return w, &busy, &now
|
||||
}
|
||||
|
||||
// Busy → the file holds the time and the sentence; idle → gone. RED-PROOF: skip the write → no file → FAILS;
|
||||
// skip the remove → the file stays → FAILS.
|
||||
func TestBusyFileFollowsTheWork(t *testing.T) {
|
||||
w, busy, now := newW(t)
|
||||
w.Tick()
|
||||
if _, err := os.Stat(w.Path); !os.IsNotExist(err) {
|
||||
t.Fatalf("idle: a busy file exists (%v)", err)
|
||||
}
|
||||
*busy = true
|
||||
w.Tick()
|
||||
raw, err := os.ReadFile(w.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("busy: no file: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
if len(lines) != 2 || lines[0] != now.Format(time.RFC3339) || lines[1] != "a backup or a restore is running" {
|
||||
t.Fatalf("file %q", raw)
|
||||
}
|
||||
if fi, _ := os.Stat(w.Path); fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode %v, want 0600", fi.Mode().Perm())
|
||||
}
|
||||
// it is REWRITTEN while busy: freshness is the agent's signal
|
||||
*now = now.Add(15 * time.Second)
|
||||
w.Tick()
|
||||
raw, _ = os.ReadFile(w.Path)
|
||||
if !strings.HasPrefix(string(raw), now.Format(time.RFC3339)) {
|
||||
t.Fatalf("not rewritten: %q", raw)
|
||||
}
|
||||
*busy = false
|
||||
w.Tick()
|
||||
if _, err := os.Stat(w.Path); !os.IsNotExist(err) {
|
||||
t.Fatalf("idle again: the busy file is still there (%v) — the agent would refuse a reboot for 90 s more", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The first probe that runs names the work; a nil probe is ignored.
|
||||
func TestBusyNamesTheRunningWork(t *testing.T) {
|
||||
w, _, _ := newW(t)
|
||||
w.AddProbe("an app update is running", func() bool { return true })
|
||||
w.AddProbe("ignored", nil)
|
||||
if got := w.Busy(); got != "an app update is running" {
|
||||
t.Fatalf("Busy() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -953,6 +953,7 @@
|
||||
"dashboard.tunnel_offline_title": "The secure internet connection is not connected now, so your apps cannot be reached from outside. Check the internet connection.",
|
||||
"dashboard.utolso_mentes": "Last backup:",
|
||||
"dashboard.vedett": "Protected",
|
||||
"dashboard.operator_reboot": "Felhom support restarted your box at %s.",
|
||||
"dashboard.vezerlopult": "Dashboard",
|
||||
"datapath.consequence.excluded": "This folder is temporary and is not backed up.",
|
||||
"datapath.consequence.import_excluded": "Copy the files to be processed in here. The app reads them and then deletes them from here — this folder is temporary and is not backed up.",
|
||||
|
||||
@@ -948,6 +948,7 @@
|
||||
"dashboard.tunnel_offline_title": "A biztonságos internetkapcsolat most nem kapcsolódik, ezért az alkalmazásaid kívülről nem érhetők el. Ellenőrizd az internetkapcsolatot.",
|
||||
"dashboard.utolso_mentes": "Utolsó mentés:",
|
||||
"dashboard.vedett": "Védett",
|
||||
"dashboard.operator_reboot": "A Felhom ügyfélszolgálata %s-kor újraindította a dobozt.",
|
||||
"dashboard.vezerlopult": "Vezérlőpult",
|
||||
"datapath.consequence.excluded": "Ez a mappa átmeneti, és nem készül róla biztonsági mentés.",
|
||||
"datapath.consequence.import_excluded": "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — ez a mappa átmeneti, és nem készül róla biztonsági mentés.",
|
||||
|
||||
@@ -160,6 +160,26 @@ func isObservedUp(s Stack) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// RecordStopWantedIfDown (R-1350) is the operator's hold clear (D64 = B) speaking for the household: the clear starts
|
||||
// nothing, so an app that is NOT up stays stopped — and that stop is now WANTED, recorded as „stopped" so the dead-app
|
||||
// scan does not mail „Telepített alkalmazás nem fut" 30 s after the clear (measured 2026-10-29 on Tester 1), and the
|
||||
// boot reconciler does not start it at the next boot. A later Start records „running" and ends it. A running app's wish
|
||||
// is left alone (recorded=false). The clear is a PERSON's act (R-379: „a person — on the box, or the operator on the
|
||||
// hub"), so this is the ownership rule's own exception, not a machine writing intent.
|
||||
func (m *Manager) RecordStopWantedIfDown(name string) (bool, error) {
|
||||
st, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if isObservedUp(*st) {
|
||||
return false, nil
|
||||
}
|
||||
if err := m.SetDesiredState(name, DesiredStateStopped); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// WantsStopped (R-721, v0.283.0) reports whether the household's own recorded intent for the stack is
|
||||
// „stopped". The machines that stop an app for their own purposes and start it again afterwards — the
|
||||
// whole-guest backup's quiesce, the nightly volume dump — ask this before their restart, so a Stop the
|
||||
|
||||
@@ -336,3 +336,27 @@ func TestDesiredStateOf_NilAppConfigIsUnknown(t *testing.T) {
|
||||
t.Fatalf("a stack with no AppConfig reported desired state %q — absent must read as unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1350: the operator's hold clear records „stopped" for an app that is NOT up, and leaves a running app's wish alone
|
||||
// (a clear never stops anything). RED-PROOF: write „stopped" without the up check → the running case FAILS.
|
||||
func TestR1350_RecordStopWantedIfDown(t *testing.T) {
|
||||
m, root := newDSManager(t, "gokapi", "immich")
|
||||
for _, n := range []string{"gokapi", "immich"} {
|
||||
writeAppYAML(t, stackDirOf(root, n), "deployed: true\ndesired_state: running\n")
|
||||
m.stacks[n].AppConfig = LoadAppConfig(stackDirOf(root, n))
|
||||
}
|
||||
m.stacks["gokapi"].State = StateStopped
|
||||
m.stacks["immich"].State = StateRunning
|
||||
if rec, err := m.RecordStopWantedIfDown("gokapi"); err != nil || !rec {
|
||||
t.Fatalf("gokapi: recorded=%v err=%v", rec, err)
|
||||
}
|
||||
if got := LoadAppConfig(stackDirOf(root, "gokapi")).DesiredState; got != DesiredStateStopped {
|
||||
t.Fatalf("gokapi desired_state on disk = %q, want stopped", got)
|
||||
}
|
||||
if rec, err := m.RecordStopWantedIfDown("immich"); err != nil || rec {
|
||||
t.Fatalf("immich (running): recorded=%v err=%v", rec, err)
|
||||
}
|
||||
if got := LoadAppConfig(stackDirOf(root, "immich")).DesiredState; got != DesiredStateRunning {
|
||||
t.Fatalf("a running app's wish was changed to %q", got)
|
||||
}
|
||||
}
|
||||
@@ -183,9 +183,10 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||||
|
||||
data := s.baseData("dashboard", "Vezérlőpult")
|
||||
data["TitleKey"] = "page.title.dashboard" // i18n: the Hungarian title above is what hu renders
|
||||
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
|
||||
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
|
||||
data["TitleKey"] = "page.title.dashboard" // i18n: the Hungarian title above is what hu renders
|
||||
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
|
||||
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
|
||||
data["OperatorRebootNotice"] = s.operatorRebootNotice(s.langFor(r), time.Now()) // D65: „Felhom support restarted your box"
|
||||
// R-560: the app rows carry catalog copy (each row's `.Meta.Description`), so the list the
|
||||
// TEMPLATE sees is the localised view. The lists the warning helpers below see are the
|
||||
// originals — they key on stack NAMES, and handing them a translated copy would only widen
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
)
|
||||
|
||||
// „Felhom support restarted your box at <time>" (D65 = A, operator ruling 2026-10-29 15:50; felhom.eu `04` §6.1).
|
||||
//
|
||||
// The operator can reboot the guest from the hub; the household must see afterwards that Felhom did it. The agent
|
||||
// writes its record BEFORE the reboot, so this controller — which starts with the guest — asks for it once at start
|
||||
// (GET /guest/operator-reboot) and the dashboard shows one neutral line until a day has passed since the reboot.
|
||||
// Pinned by operator_reboot_notice_test.go.
|
||||
|
||||
// operatorRebootNoticeFor is how long the line shows (the agent also stops answering after a day).
|
||||
const operatorRebootNoticeFor = 24 * time.Hour
|
||||
|
||||
// SetOperatorReboot records the last operator reboot (the zero time = none).
|
||||
func (s *Server) SetOperatorReboot(at time.Time) {
|
||||
s.opRebootMu.Lock()
|
||||
defer s.opRebootMu.Unlock()
|
||||
s.opRebootAt = at
|
||||
}
|
||||
|
||||
// operatorRebootNotice is the dashboard's line for lang at now ("" = none).
|
||||
func (s *Server) operatorRebootNotice(lang string, now time.Time) string {
|
||||
s.opRebootMu.Lock()
|
||||
at := s.opRebootAt
|
||||
s.opRebootMu.Unlock()
|
||||
if at.IsZero() || now.Sub(at) >= operatorRebootNoticeFor || now.Before(at) {
|
||||
return ""
|
||||
}
|
||||
return s.i18n.Msgf(lang, "dashboard.operator_reboot", at.In(budapestLocation()).Format("15:04"))
|
||||
}
|
||||
|
||||
// WatchOperatorReboot asks the agent once at start, retrying while the agent does not answer (it may start after us),
|
||||
// at most for opRebootAskFor. A 404 (an older agent) ends it at once.
|
||||
func (s *Server) WatchOperatorReboot(ctx context.Context) {
|
||||
fetch := s.opRebootFetch
|
||||
if fetch == nil {
|
||||
fetch = func(ctx context.Context) (time.Time, error) {
|
||||
c, err := s.agentClient()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return c.OperatorReboot(ctx)
|
||||
}
|
||||
}
|
||||
every := s.opRebootRetry
|
||||
if every <= 0 {
|
||||
every = 30 * time.Second
|
||||
}
|
||||
for deadline := time.Now().Add(10 * time.Minute); ; {
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
at, err := fetch(cctx)
|
||||
cancel()
|
||||
var se *agentapi.StatusError
|
||||
switch {
|
||||
case err == nil:
|
||||
if !at.IsZero() {
|
||||
s.SetOperatorReboot(at)
|
||||
s.logger.Printf("[INFO] [web] the operator rebooted this box from the hub at %s — the dashboard says so for a day", at.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return
|
||||
case errors.As(err, &se) && se.Code == 404:
|
||||
return // an agent older than the route cannot reboot from the hub
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
s.logger.Printf("[WARN] [web] could not ask the agent about an operator reboot: %v — no notice on the dashboard", err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(every):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
)
|
||||
|
||||
// D65 = A: after the operator rebooted the box from the hub, the household's dashboard says so — one neutral line,
|
||||
// for one day, then gone.
|
||||
|
||||
// The real dashboard, through ServeHTTP: the line is there after an operator reboot, in Hungarian, with the Budapest
|
||||
// time; it is gone a day later. RED-PROOF: drop the data["OperatorRebootNotice"] line from dashboardHandler → FAILS;
|
||||
// drop the 24-hour check → the day-later page still shows it → FAILS.
|
||||
func TestOperatorRebootNotice_OnTheDashboardForADay(t *testing.T) {
|
||||
s := newDashboardServer(t, time.Time{})
|
||||
if body := getPage(t, s, "/dashboard").Body.String(); strings.Contains(body, "data-operator-reboot") {
|
||||
t.Fatal("no reboot yet, but the dashboard shows the line")
|
||||
}
|
||||
at := time.Now().Add(-2 * time.Hour)
|
||||
s.SetOperatorReboot(at)
|
||||
body := getPage(t, s, "/dashboard").Body.String()
|
||||
want := "A Felhom ügyfélszolgálata " + at.In(budapestLocation()).Format("15:04") + "-kor újraindította a dobozt."
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("the dashboard lacks %q", want)
|
||||
}
|
||||
s.SetOperatorReboot(time.Now().Add(-25 * time.Hour))
|
||||
if body := getPage(t, s, "/dashboard").Body.String(); strings.Contains(body, "data-operator-reboot") {
|
||||
t.Fatal("a day later the line must be gone")
|
||||
}
|
||||
}
|
||||
|
||||
// English: the reader's language.
|
||||
func TestOperatorRebootNotice_English(t *testing.T) {
|
||||
s := newDashboardServer(t, time.Time{})
|
||||
at := time.Date(2026, 10, 30, 9, 41, 0, 0, time.UTC)
|
||||
s.SetOperatorReboot(at)
|
||||
got := s.operatorRebootNotice("en", at.Add(time.Hour))
|
||||
if got != "Felhom support restarted your box at "+at.In(budapestLocation()).Format("15:04")+"." {
|
||||
t.Fatalf("en: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The start-up ask: it retries while the agent does not answer, keeps the time it gets, and stops at once on a 404
|
||||
// (an agent older than the route cannot reboot from the hub). RED-PROOF: drop SetOperatorReboot from the success
|
||||
// branch → FAILS; return on the first error → the retried answer is lost → FAILS.
|
||||
func TestWatchOperatorReboot_RetriesAndKeepsTheTime(t *testing.T) {
|
||||
s := &Server{logger: log.New(io.Discard, "", 0), opRebootRetry: time.Millisecond}
|
||||
at := time.Date(2026, 10, 30, 9, 41, 0, 0, time.UTC)
|
||||
calls := 0
|
||||
s.opRebootFetch = func(context.Context) (time.Time, error) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return time.Time{}, errors.New("agentapi: GET /guest/operator-reboot: connection refused")
|
||||
}
|
||||
return at, nil
|
||||
}
|
||||
s.WatchOperatorReboot(context.Background())
|
||||
if calls != 3 || !s.opRebootAt.Equal(at) {
|
||||
t.Fatalf("calls=%d at=%v, want 3 and %v", calls, s.opRebootAt, at)
|
||||
}
|
||||
|
||||
s2 := &Server{logger: log.New(io.Discard, "", 0), opRebootRetry: time.Millisecond}
|
||||
calls = 0
|
||||
s2.opRebootFetch = func(context.Context) (time.Time, error) {
|
||||
calls++
|
||||
return time.Time{}, &agentapi.StatusError{Path: "/guest/operator-reboot", Code: 404}
|
||||
}
|
||||
s2.WatchOperatorReboot(context.Background())
|
||||
if calls != 1 || !s2.opRebootAt.IsZero() {
|
||||
t.Fatalf("a 404 must end the ask at once: calls=%d at=%v", calls, s2.opRebootAt)
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,11 @@ type Server struct {
|
||||
agentCli *agentapi.Client
|
||||
agentCliErr error
|
||||
agentCliOnce sync.Once
|
||||
// D65 = A: the last operator reboot of this guest (operator_reboot_notice.go); the fetch and retry are test seams.
|
||||
opRebootMu sync.Mutex
|
||||
opRebootAt time.Time
|
||||
opRebootFetch func(ctx context.Context) (time.Time, error)
|
||||
opRebootRetry time.Duration
|
||||
|
||||
// initialCredsFn is the R-254 read seam for an app's generated first-login credential. nil → the
|
||||
// real live container read (stackMgr.ReadInitialCredentials). ONE definition, used by BOTH the
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h2>{{T "dashboard.vezerlopult"}}</h2>
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
</div>
|
||||
{{template "media_notice" .}}
|
||||
{{template "media_notice" .}}{{if .OperatorRebootNotice}}<div class="operator-reboot-notice" data-operator-reboot>{{.OperatorRebootNotice}}</div>{{end}}
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
|
||||
@@ -911,6 +911,8 @@ select.form-control option { background: var(--bg-2); color: var(--text-1); }
|
||||
|
||||
/* Inline storage warnings (under storage bars on dashboard and monitoring) */
|
||||
.inline-warnings { margin-top: 0.75rem; }
|
||||
/* D65: „Felhom support restarted your box" — neutral (a deliberate act, not a fault: the exception-colour rule). */
|
||||
.operator-reboot-notice { margin: 0 0 1rem; padding: 0.6rem 0.9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); color: var(--text-2); }
|
||||
.inline-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -344,7 +344,8 @@
|
||||
"api.escrow.recovery_offer_open": "BORN AS A KEY (R-1219, brief 2026-10-26 night Part 3) -- the escrow start handler refuses during a recovery offer. Pinned by TestR1219_NoCeremonyDuringARecoveryOffer.",
|
||||
"err.appexport.kulonleges_fajl": "BORN AS A KEY (R-1187 a, brief 2026-10-26 night Part 4) -- the export refuses a FIFO, socket or device, naming the path. Pinned by TestR1187a_AFifoFailsTheExportNamingThePath.",
|
||||
"err.appexport.import_foglalt": "BORN AS A KEY (R-1187 d, brief 2026-10-26 night Part 4) -- an import is refused while another operation holds the box. Pinned by TestR1187d_AnImportWaitsForARestore.",
|
||||
"err.appexport.hdd_path_hibas": "BORN AS A KEY (R-1187 f, brief 2026-10-26 night Part 4) -- a bundle with an empty or relative HDD_PATH is refused before anything is touched. Pinned by TestR1187f_AnEmptyOrRelativeHDDPathIsRefused."
|
||||
"err.appexport.hdd_path_hibas": "BORN AS A KEY (R-1187 f, brief 2026-10-26 night Part 4) -- a bundle with an empty or relative HDD_PATH is refused before anything is touched. Pinned by TestR1187f_AnEmptyOrRelativeHDDPathIsRefused.",
|
||||
"dashboard.operator_reboot": "BORN AS A KEY (D65 = A, 2026-10-29) -- the household's line after the operator rebooted the box from the hub; no Go literal ever said it"
|
||||
},
|
||||
"flash.share.already_on": "A megosztás már be van kapcsolva.",
|
||||
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
||||
|
||||
Reference in new issue
Block a user