R-717: after_setup open_command — the family window reopens an app's DB-held sign-up switch

The command form ran only when the lock was SET, so a database switch closed by
`command` stayed closed through the household's 15-minute window. New twin
fields `open_command` + `open_success` (same service/user/args_env and the same
argv-safe expansion and success-marker rules as `command`/`success`).

- liftNativeLock (the window, via OpenSignupWindow → goNativeLock(false)): marks
  the gate record native_lock "opening" BEFORE anything opens, lifts the env,
  runs open_command; only full success records "lifted". A failed open closes
  the switch again at once and records after_setup {ok: false, step: open}; the
  app page shows its own line (app_info.signup_native_open_failed).
- The close: reconcileSignupBlocks (every 20 s and at controller start) runs
  `command` for any non-"applied" state once no window runs. A failed close
  after a window is logged ERROR ("may still be OPEN past the household's
  window") and retried every nativeLockOpenRetry (2 min) instead of 30.
- After a successful app update, verifyAndConclude → markNativeLockForReapply
  sets native_lock "" so the loop closes the switch again.
- A template with `command` but no `open_command` keeps today's window (env
  only) and logs once per app that its own switch cannot be reopened.

Tests: internal/stacks/after_setup_r717_test.go (7), web render + parity case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-06 15:12:14 +02:00
parent 9c945688c0
commit 5b8656974b
13 changed files with 1080 additions and 39 deletions
+1
View File
@@ -2582,6 +2582,7 @@
"app_info.close_signup_text": "Anyone who finds this app's address can sign up. Close sign-up now — you can still add your family afterwards.",
"app_info.close_signup_btn": "Close sign-up now",
"app_info.signup_native_failed": "The app's own sign-up switch could not be closed. The address block still protects it.",
"app_info.signup_native_open_failed": "The app's own sign-up switch could not be opened for the 15 minutes, so it stays closed. A new family member cannot sign up in the app itself. Try again later.",
"app_info.signup_window_restart": "The app restarts for this, and once more when the 15 minutes end.",
"app_info.restarts_once": "For this the app restarts once and is briefly unavailable meanwhile.",
"err.setup_gate.close_signup_not_offered": "There is nothing to close on this app.",
+1
View File
@@ -2570,6 +2570,7 @@
"app_info.close_signup_text": "Ennél az alkalmazásnál bárki regisztrálhat, aki megtalálja a címét. Zárd le a regisztrációt most — a családtagjaidat utána is fel tudod venni.",
"app_info.close_signup_btn": "Regisztráció lezárása most",
"app_info.signup_native_failed": "Az alkalmazás saját regisztrációs kapcsolóját nem sikerült lezárni. A cím zárolása így is véd.",
"app_info.signup_native_open_failed": "Az alkalmazás saját regisztrációs kapcsolóját nem sikerült megnyitni a 15 percre, ezért zárva maradt. Új családtag így az alkalmazásban nem tud regisztrálni. Próbáld újra később.",
"app_info.signup_window_restart": "Ehhez az alkalmazás újraindul, és a 15 perc végén még egyszer.",
"app_info.restarts_once": "Ehhez az alkalmazás egyszer újraindul, és közben rövid ideig nem érhető el.",
"err.setup_gate.close_signup_not_offered": "Ennél az alkalmazásnál nincs mit lezárni.",
@@ -46,6 +46,9 @@ type AfterInstallRecord struct {
At string `yaml:"at" json:"at"`
OK bool `yaml:"ok" json:"ok"`
Detail string `yaml:"detail,omitempty" json:"detail,omitempty"`
// Step (R-717, after_setup only): "open" = the household's window could not open the app's own switch (it was
// closed again); "" = a close.
Step string `yaml:"step,omitempty" json:"step,omitempty"`
}
// afterInstallTries / afterInstallGap: the app may still be booting when the deploy reports done.
+149 -35
View File
@@ -29,6 +29,26 @@ import (
// restarts.
//
// A command form (`service`, `command`, `success`, `env` names) follows after_install's argv-safe rules (R-713).
//
// R-717: an app whose switch lives in its own database (opengist, wishlist) is closed by `command` and reopened for
// the household's window by its twin:
//
// after_setup:
// service: app # shared by both commands, as is `user` and `args_env`
// command: [...] # CLOSES the switch; `success` must appear in its output
// success: "closed"
// open_command: [...] # OPENS it for the 15-minute window; `open_success` must appear in its output
// open_success: "opened"
//
// Where each runs: the window (OpenSignupWindow → liftNativeLock) runs `open_command` after the env lift; the loop
// (reconcileSignupBlocks, every 20 s and once at controller start) runs `command` again for every app whose state is
// not "applied" once no window runs — after the window, after a box restart, and after an app update
// (markNativeLockForReapply in verifyAndConclude). Fail closed: the state is marked "opening" BEFORE anything opens,
// so a crash mid-open leaves a state the loop closes; an `open_command` that fails closes the switch again at once and
// records `after_setup: {ok: false, step: open}` (the app page says the switch could not be opened); a close that fails
// keeps the state not-"applied", logs ERROR and is retried by the loop every nativeLockOpenRetry. A template with
// `command` but no `open_command` keeps today's window (env lift only) and logs once that the app's own switch cannot
// be reopened.
// Pinned by internal/stacks/after_setup_test.go.
// AfterSetupSpec is `.felhom.yml`'s `after_setup:`.
@@ -39,14 +59,23 @@ type AfterSetupSpec struct {
Args []string `yaml:"args_env,omitempty" json:"args_env,omitempty"` // deploy values a command may use
Command []string `yaml:"command,omitempty" json:"command,omitempty"`
Success string `yaml:"success,omitempty" json:"success,omitempty"`
// OpenCommand / OpenSuccess (R-717): the twin that opens the switch `command` closes, for the household's window.
OpenCommand []string `yaml:"open_command,omitempty" json:"open_command,omitempty"`
OpenSuccess string `yaml:"open_success,omitempty" json:"open_success,omitempty"`
}
// Native-lock states in SetupGateRecord.NativeLock.
const (
NativeLockApplied = "applied" // the app's own switch says closed
NativeLockLifted = "lifted" // the household's window: the switch is open again
// NativeLockOpening (R-717) is written BEFORE the window opens anything: a crash between the opening and its
// record leaves a state that is not "applied", so the loop closes it once the window has passed.
NativeLockOpening = "opening"
)
// afterSetupStepOpen marks an AfterInstallRecord written by a failed OPEN (the window), not a failed close.
const afterSetupStepOpen = "open"
// afterSetupUp starts the app from its stored app.yaml (a seam: tests never reach Docker).
func (m *Manager) afterSetupUp(name string) error {
if m.afterSetupUpFn != nil {
@@ -79,73 +108,158 @@ func (m *Manager) setNativeEnv(name, dir string, spec *AfterSetupSpec, lock bool
}
// applyNativeLock sets (lock=true) or lifts the app's own switch, then starts the app once when anything changed.
// Records the outcome. Safe to call again: an unchanged env starts nothing.
// Records the outcome. Safe to call again: an unchanged env starts nothing. A lift goes to liftNativeLock (R-717).
func (m *Manager) applyNativeLock(name string, lock bool, why string) error {
st, ok := m.GetStack(name)
if !ok || st.Meta.AfterSetup == nil {
return nil
}
if !lock {
return m.liftNativeLock(name, st, why)
}
spec := st.Meta.AfterSetup
dir := filepath.Dir(st.ComposePath)
record := func(ok bool, detail string) {
rec := &AfterInstallRecord{At: m.now().UTC().Format(time.RFC3339), OK: ok, Detail: truncateStr(detail, 300)}
state := NativeLockApplied
if !lock {
state = NativeLockLifted
}
m.mutateAppConfig(name, dir, "after_setup", func(cfg *AppConfig) bool {
cfg.AfterSetup = rec
if ok && cfg.SetupGate != nil {
cfg.SetupGate.NativeLock = state
}
return true
})
priorState := ""
if st.AppConfig != nil && st.AppConfig.SetupGate != nil {
priorState = st.AppConfig.SetupGate.NativeLock
}
var err error
// The switch works only if the app's INSTALLED compose reads the variable. An app installed before the template
// wired it (decision 49's apps) carries the old compose until its next update: say so, never report a lock that
// is not there. The address block still holds.
if len(spec.Env) > 0 && lock {
if len(spec.Env) > 0 {
if miss := composeMissingVars(st.ComposePath, spec.Env); len(miss) > 0 {
err = fmt.Errorf("the installed version of the app does not read %v yet — its own switch applies after its next update", miss)
m.logger.Printf("[WARN] [stacks] %s: %v", name, err)
record(false, err.Error())
m.recordNativeLock(name, dir, false, err.Error(), "", "")
return err
}
}
if len(spec.Env) > 0 {
if m.setNativeEnv(name, dir, spec, lock) {
if m.setNativeEnv(name, dir, spec, true) {
err = m.afterSetupUp(name)
}
}
if err == nil && lock && len(spec.Command) > 0 {
err = m.runAfterSetupCommand(name, dir, spec)
if err == nil && len(spec.Command) > 0 {
err = m.runAfterSetupCommand(name, dir, spec, spec.Command, spec.Success, "command")
}
if err != nil {
m.logger.Printf("[ERROR] [stacks] %s: the app's own sign-up switch could not be %s (%s): %v — the address block still holds", name, map[bool]string{true: "closed", false: "opened"}[lock], why, err)
record(false, err.Error())
if priorState == NativeLockLifted || priorState == NativeLockOpening {
// R-717: the household's window was (or may have been) opened — the app's own sign-up may still be OPEN.
m.logger.Printf("[ERROR] [stacks] %s: the app's own sign-up switch could NOT be closed again (%s): %v — sign-up inside the app may still be OPEN past the household's window; the address block holds and the loop retries every %s", name, why, err, nativeLockOpenRetry)
} else {
m.logger.Printf("[ERROR] [stacks] %s: the app's own sign-up switch could not be closed (%s): %v — the address block still holds", name, why, err)
}
m.recordNativeLock(name, dir, false, err.Error(), "", "")
return err
}
keys := make([]string, 0, len(spec.Env))
for k := range spec.Env {
keys = append(keys, k)
}
sort.Strings(keys)
m.logger.Printf("[INFO] [stacks] %s: the app's own sign-up switch %s (%s; env %v)", name, map[bool]string{true: "CLOSED", false: "opened for the household's window"}[lock], why, keys)
record(true, "")
m.logger.Printf("[INFO] [stacks] %s: the app's own sign-up switch CLOSED (%s; env %v, command %v)", name, why, sortedEnvKeys(spec.Env), len(spec.Command) > 0)
m.recordNativeLock(name, dir, true, "", "", NativeLockApplied)
return nil
}
// runAfterSetupCommand runs the command form once, with after_install's argv-safe expansion and success marker.
func (m *Manager) runAfterSetupCommand(name, dir string, spec *AfterSetupSpec) error {
if spec.Service == "" || spec.Success == "" {
return fmt.Errorf("after_setup command needs a service and a success marker")
// liftNativeLock is the household's window (R-717): the env keys go (one start) and the template's open_command runs.
// Fail closed: the state says "opening" before anything opens; an open that fails closes the switch again at once and
// records the failure with step "open" (the app page says it could not be opened). Only a full success records
// "lifted", which the loop closes again once the window has passed.
func (m *Manager) liftNativeLock(name string, st *Stack, why string) error {
spec := st.Meta.AfterSetup
dir := filepath.Dir(st.ComposePath)
if len(spec.Command) > 0 && len(spec.OpenCommand) == 0 {
if _, seen := m.nativeOpenMissingLogged.LoadOrStore(name, true); !seen {
m.logger.Printf("[WARN] [stacks] %s: the template closes the app's own sign-up switch with a command but has no open_command — the household's window cannot reopen it (only the address block opens)", name)
}
}
if len(spec.Env) == 0 && len(spec.OpenCommand) == 0 {
return nil // nothing of the app's own to open
}
m.mutateAppConfig(name, dir, "after_setup_opening", func(cfg *AppConfig) bool {
if cfg.SetupGate == nil {
return false
}
cfg.SetupGate.NativeLock = NativeLockOpening
return true
})
if c := LoadAppConfig(dir); c == nil || c.SetupGate == nil || c.SetupGate.NativeLock != NativeLockOpening {
err := fmt.Errorf("the record could not be marked before opening — the app's own switch stays closed")
m.logger.Printf("[ERROR] [stacks] %s: the app's own sign-up switch was NOT opened (%s): %v", name, why, err)
m.recordNativeLock(name, dir, false, err.Error(), afterSetupStepOpen, "")
return err
}
var err error
if len(spec.Env) > 0 && m.setNativeEnv(name, dir, spec, false) {
err = m.afterSetupUp(name)
}
if err == nil && len(spec.OpenCommand) > 0 {
err = m.runAfterSetupCommand(name, dir, spec, spec.OpenCommand, spec.OpenSuccess, "open_command")
}
if err != nil {
m.logger.Printf("[ERROR] [stacks] %s: the app's own sign-up switch could NOT be opened (%s): %v — closing it again; a new family member cannot sign up in the app itself", name, why, err)
detail := "could not be opened for the household's window: " + err.Error()
if cerr := m.applyNativeLock(name, true, "an opening that failed"); cerr != nil {
detail += "; closing it again failed too (the loop retries): " + cerr.Error()
}
m.recordNativeLock(name, dir, false, detail, afterSetupStepOpen, "")
return err
}
m.logger.Printf("[INFO] [stacks] %s: the app's own sign-up switch opened for the household's window (%s; env %v, open_command %v)", name, why, sortedEnvKeys(spec.Env), len(spec.OpenCommand) > 0)
m.recordNativeLock(name, dir, true, "", "", NativeLockLifted)
return nil
}
// recordNativeLock writes the after_setup record; state != "" also moves SetupGate.NativeLock.
func (m *Manager) recordNativeLock(name, dir string, ok bool, detail, step, state string) {
rec := &AfterInstallRecord{At: m.now().UTC().Format(time.RFC3339), OK: ok, Detail: truncateStr(detail, 300), Step: step}
m.mutateAppConfig(name, dir, "after_setup", func(cfg *AppConfig) bool {
cfg.AfterSetup = rec
if state != "" && cfg.SetupGate != nil {
cfg.SetupGate.NativeLock = state
}
return true
})
}
// markNativeLockForReapply (R-717) makes the loop run the close again after an app update: an update may bring a
// database whose switch is not the one the box set. NativeLock "" = "run once the app is up" (as after a restore).
func (m *Manager) markNativeLockForReapply(name, dir string) {
st, ok := m.GetStack(name)
if !ok || st.Meta.AfterSetup == nil {
return
}
marked := false
m.mutateAppConfig(name, dir, "after_setup_reapply", func(cfg *AppConfig) bool {
if cfg.SetupGate == nil || cfg.SetupGate.State != SetupGateOpen || cfg.SetupGate.NativeLock != NativeLockApplied {
return false // never set, or not applied: the loop already runs it
}
cfg.SetupGate.NativeLock = ""
marked = true
return true
})
if marked {
m.logger.Printf("[INFO] [stacks] %s: after the update the app's own sign-up switch is closed again by the loop", name)
}
}
func sortedEnvKeys(mp map[string]string) []string {
keys := make([]string, 0, len(mp))
for k := range mp {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// runAfterSetupCommand runs one command of the command form (`command` or `open_command`) once, with after_install's
// argv-safe expansion and its success marker.
func (m *Manager) runAfterSetupCommand(name, dir string, spec *AfterSetupSpec, command []string, success, field string) error {
if spec.Service == "" || success == "" {
return fmt.Errorf("after_setup %s needs a service and a success marker", field)
}
cfg := LoadAppConfigDecrypted(dir, m.encKey)
if cfg == nil {
return fmt.Errorf("app.yaml unreadable")
}
cmd, err := expandAfterInstall(spec.Command, spec.Args, cfg.Env)
cmd, err := expandAfterInstall(command, spec.Args, cfg.Env)
if err != nil {
return err
}
@@ -156,8 +270,8 @@ func (m *Manager) runAfterSetupCommand(name, dir string, spec *AfterSetupSpec) e
args = append(args, spec.Service)
args = append(args, cmd...)
out, err := m.runInService(dir, args...)
if err != nil || !strings.Contains(out, spec.Success) {
return fmt.Errorf("after_setup command did not report %q (err %v)", spec.Success, err)
if err != nil || !strings.Contains(out, success) {
return fmt.Errorf("after_setup %s did not report %q (err %v)", field, success, err)
}
return nil
}
@@ -0,0 +1,286 @@
package stacks
import (
"bytes"
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// R-717 — the command form's "open" twin: the household's 15-minute window reopens an app's own database-held
// sign-up switch, and the loop closes it again. Never reaches Docker: afterLoadFn is the compose-exec seam.
const cmdYml = "display_name: Gated App\nsetup_gate: true\nsignup_block: \"PathPrefix(`/signup`)\"\n" +
"after_setup:\n service: gapp\n command: [\"sh\", \"-c\", \"close-signup\"]\n success: \"SIGNUP-CLOSED\"\n" +
" open_command: [\"sh\", \"-c\", \"open-signup\"]\n open_success: \"SIGNUP-OPENED\"\n" +
"deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n"
// fakeSwitch is the app's own database switch behind the exec seam.
type fakeSwitch struct {
mu sync.Mutex
open bool
opens int
closes int
failOpen bool
failClose bool
onOpen func() // runs after the switch opened, before the command returns (a crash point)
}
func (f *fakeSwitch) exec(dir string, args ...string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
last := args[len(args)-1]
switch last {
case "open-signup":
f.opens++
if f.failOpen {
return "error: database is locked", fmt.Errorf("exit 1")
}
f.open = true
if f.onOpen != nil {
f.onOpen()
}
return "SIGNUP-OPENED", nil
case "close-signup":
f.closes++
if f.failClose {
return "error: database is locked", fmt.Errorf("exit 1")
}
f.open = false
return "SIGNUP-CLOSED", nil
}
return "", fmt.Errorf("unexpected exec %v", args)
}
func (f *fakeSwitch) counts() (opens, closes int, open bool) {
f.mu.Lock()
defer f.mu.Unlock()
return f.opens, f.closes, f.open
}
func wireCmdManager(m *Manager, f *fakeSwitch) *bytes.Buffer {
m.afterSetupSync = true
m.afterSetupUpFn = func(string) error { return nil }
m.afterLoadFn = f.exec
var buf bytes.Buffer
m.logger = log.New(&buf, "", 0)
return &buf
}
func cmdManager(t *testing.T, yml string) (*Manager, *fakeSwitch, *bytes.Buffer, string) {
t.Helper()
m := gateManager(t, yml)
f := &fakeSwitch{}
buf := wireCmdManager(m, f)
dir := closedGate(t, m)
must(t, m.OpenSetupGate("gapp", SetupGateByHousehold)) // the first close
if _, closes, open := f.counts(); closes != 1 || open {
t.Fatalf("the gate opening did not close the app's own switch: closes=%d open=%v", closes, open)
}
return m, f, buf, dir
}
// The window runs open_command once; the window's end runs command once; the switch ends closed.
// COMPANION RED-PROOF (audits/design-build-2026-10-06/E/red-open-then-close.txt): on the unchanged code (no
// open_command) the window never opens the switch → "the window did not open" fails.
func TestAfterSetupR717_OpenThenCloseEachRunOnce(t *testing.T) {
m, f, _, dir := cmdManager(t, cmdYml)
_, err := m.OpenSignupWindow("gapp")
must(t, err)
if opens, closes, open := f.counts(); opens != 1 || closes != 1 || !open {
t.Fatalf("the window did not open the app's own switch: opens=%d closes=%d open=%v", opens, closes, open)
}
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockLifted || c.AfterSetup == nil || !c.AfterSetup.OK {
t.Fatalf("after the open: native=%q record=%+v", c.SetupGate.NativeLock, c.AfterSetup)
}
m.SetupGateTick() // inside the window: nothing closes
if _, closes, open := f.counts(); closes != 1 || !open {
t.Fatalf("the loop closed the switch INSIDE the window: closes=%d open=%v", closes, open)
}
later := time.Now().Add(signupWindow + time.Minute)
m.updateNowFn = func() time.Time { return later }
m.SetupGateTick()
m.SetupGateTick() // a second pass must not run it again
if opens, closes, open := f.counts(); opens != 1 || closes != 2 || open {
t.Fatalf("after the window: opens=%d closes=%d open=%v (want 1 open, 1 more close, closed)", opens, closes, open)
}
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockApplied || !c.AfterSetup.OK {
t.Fatalf("after the window: native=%q record=%+v", c.SetupGate.NativeLock, c.AfterSetup)
}
}
// A box restart inside the window ends CLOSED — even when the controller died between the open command and its
// record (the worst moment: the switch is open, nothing says so yet).
// COMPANION RED-PROOF (red-restart-mid-open.txt): drop the "opening" mark in liftNativeLock → the disk at the crash
// says "applied", the restarted loop never closes, "a restart left sign-up OPEN" fails.
func TestAfterSetupR717_ARestartInsideTheWindowEndsClosed(t *testing.T) {
m, f, _, dir := cmdManager(t, cmdYml)
var atCrash []byte
f.onOpen = func() { // the controller dies here: copy app.yaml as it is on disk at this moment
b, err := os.ReadFile(filepath.Join(dir, "app.yaml"))
must(t, err)
atCrash = b
}
_, err := m.OpenSignupWindow("gapp")
must(t, err)
if atCrash == nil {
t.Fatal("the open command never ran")
}
must(t, os.WriteFile(filepath.Join(dir, "app.yaml"), atCrash, 0o600)) // the disk the restart finds
// The restart: a new Manager over the same paths, the startup tick (RunSetupGateLoop's first pass).
m2, err := NewManager(m.cfg, log.New(&bytes.Buffer{}, "", 0))
must(t, err)
must(t, m2.ScanStacks())
buf := wireCmdManager(m2, f)
m2.mu.Lock()
m2.stacks["gapp"].State = StateRunning
m2.mu.Unlock()
later := time.Now().Add(signupWindow + time.Minute)
m2.updateNowFn = func() time.Time { return later }
m2.SetupGateTick()
if _, _, open := f.counts(); open {
t.Fatalf("a restart left sign-up OPEN in the app past its window (record at the crash: native=%q)\n%s",
LoadAppConfig(dir).SetupGate.NativeLock, buf.String())
}
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockApplied {
t.Fatalf("after the restart: native=%q", c.SetupGate.NativeLock)
}
}
// An open command that fails leaves the switch closed, records step "open", and says so in the log.
// COMPANION RED-PROOF (red-failed-open.txt): drop the re-close (applyNativeLock(true, …)) in liftNativeLock's
// failure branch → the half-opened switch stays open → "a failed open left the switch OPEN" fails.
func TestAfterSetupR717_AFailedOpenStaysClosedAndSaysSo(t *testing.T) {
m, f, buf, dir := cmdManager(t, cmdYml)
f.onOpen = nil
f.mu.Lock()
f.failOpen = true
f.open = true // the command half-ran: it flipped the switch, then failed
f.mu.Unlock()
_, err := m.OpenSignupWindow("gapp")
must(t, err)
if _, _, open := f.counts(); open {
t.Fatalf("a failed open left the switch OPEN\n%s", buf.String())
}
c := LoadAppConfig(dir)
if c.SetupGate.NativeLock == NativeLockLifted || c.SetupGate.NativeLock == NativeLockOpening {
t.Fatalf("a failed open recorded native=%q", c.SetupGate.NativeLock)
}
if c.AfterSetup == nil || c.AfterSetup.OK || c.AfterSetup.Step != afterSetupStepOpen || !strings.Contains(c.AfterSetup.Detail, "could not be opened") {
t.Fatalf("the failure is not recorded: %+v", c.AfterSetup)
}
if !strings.Contains(buf.String(), "[ERROR]") || !strings.Contains(buf.String(), "could NOT be opened") {
t.Fatalf("the failure is not logged:\n%s", buf.String())
}
}
// A close that fails at the window's end is logged loudly, never recorded as closed, and retried by the loop.
// COMPANION RED-PROOF: none separate — the retry gap is pinned by asserting the second pass (nativeLockOpenRetry).
func TestAfterSetupR717_AFailedCloseIsRetriedNotTrusted(t *testing.T) {
m, f, buf, dir := cmdManager(t, cmdYml)
_, err := m.OpenSignupWindow("gapp")
must(t, err)
f.mu.Lock()
f.failClose = true
f.mu.Unlock()
t0 := time.Now().Add(signupWindow + time.Minute)
m.updateNowFn = func() time.Time { return t0 }
m.SetupGateTick()
c := LoadAppConfig(dir)
if c.SetupGate.NativeLock == NativeLockApplied || c.AfterSetup.OK {
t.Fatalf("a failed close was recorded as closed: native=%q record=%+v", c.SetupGate.NativeLock, c.AfterSetup)
}
if !strings.Contains(buf.String(), "may still be OPEN past the household's window") {
t.Fatalf("the failed close is not loud:\n%s", buf.String())
}
f.mu.Lock()
f.failClose = false
f.mu.Unlock()
t1 := t0.Add(nativeLockOpenRetry + time.Second)
m.updateNowFn = func() time.Time { return t1 }
m.SetupGateTick()
if _, _, open := f.counts(); open {
t.Fatal("the loop did not retry the close")
}
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockApplied || !c.AfterSetup.OK {
t.Fatalf("after the retry: native=%q record=%+v", c.SetupGate.NativeLock, c.AfterSetup)
}
}
// After a successful app update, the loop runs the close again.
// COMPANION RED-PROOF (red-close-after-update.txt): drop markNativeLockForReapply in verifyAndConclude → the close
// does not run again → "the update did not re-run the close" fails.
func TestAfterSetupR717_TheCloseRunsAgainAfterAnUpdate(t *testing.T) {
m, f, _, dir := cmdManager(t, cmdYml)
m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return true, "ok" }
f.mu.Lock()
f.open = true // the update brought a database whose switch is open
f.mu.Unlock()
m.verifyAndConclude(context.Background(), "gapp", dir, nil, UpdateRestorePoint{}, time.Now(), &updateJournalEntry{})
m.mu.Lock()
m.stacks["gapp"].State = StateRunning
m.mu.Unlock()
m.SetupGateTick()
if _, closes, open := f.counts(); closes != 2 || open {
t.Fatalf("the update did not re-run the close: closes=%d open=%v", closes, open)
}
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockApplied {
t.Fatalf("after the update: native=%q", c.SetupGate.NativeLock)
}
}
// A template that closes by command but has no open_command: the window opens the address only, the app's switch
// stays closed, and the log says so ONCE (two presses).
// COMPANION RED-PROOF: drop the LoadOrStore guard → the warning appears twice and this fails.
func TestAfterSetupR717_NoOpenCommandLogsOnceAndKeepsTheWindow(t *testing.T) {
yml := strings.Replace(cmdYml, " open_command: [\"sh\", \"-c\", \"open-signup\"]\n open_success: \"SIGNUP-OPENED\"\n", "", 1)
m, f, buf, dir := cmdManager(t, yml)
for i := 0; i < 2; i++ {
_, err := m.OpenSignupWindow("gapp")
must(t, err)
}
if opens, closes, open := f.counts(); opens != 0 || closes != 1 || open {
t.Fatalf("opens=%d closes=%d open=%v", opens, closes, open)
}
if n := strings.Count(buf.String(), "has no open_command"); n != 1 {
t.Fatalf("the missing open_command was logged %d times, want once:\n%s", n, buf.String())
}
if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) {
t.Fatal("the window did not open the address")
}
if c := LoadAppConfig(dir); c.SetupGate.NativeLock != NativeLockApplied {
t.Fatalf("native=%q — nothing of the app's own was opened", c.SetupGate.NativeLock)
}
}
// The env form keeps today's behaviour beside an open_command: env lifted AND the command run.
func TestAfterSetupR717_EnvAndOpenCommandTogether(t *testing.T) {
yml := strings.Replace(cmdYml, "after_setup:\n", "after_setup:\n env:\n SIGNUP_CLOSED: \"true\"\n", 1)
m := gateManager(t, yml)
must(t, os.WriteFile(filepath.Join(m.cfg.Paths.StacksDir, "gapp", "docker-compose.yml"), []byte(nativeCompose), 0o644))
must(t, m.ScanStacks())
f := &fakeSwitch{}
wireCmdManager(m, f)
dir := closedGate(t, m)
must(t, m.OpenSetupGate("gapp", SetupGateByHousehold))
_, err := m.OpenSignupWindow("gapp")
must(t, err)
c := LoadAppConfig(dir)
if opens, _, open := f.counts(); c.Env["SIGNUP_CLOSED"] != "" || opens != 1 || !open || c.SetupGate.NativeLock != NativeLockLifted {
t.Fatalf("window: env=%q opens=%d open=%v native=%q", c.Env["SIGNUP_CLOSED"], opens, open, c.SetupGate.NativeLock)
}
later := time.Now().Add(signupWindow + time.Minute)
m.updateNowFn = func() time.Time { return later }
m.SetupGateTick()
c = LoadAppConfig(dir)
if _, _, open := f.counts(); c.Env["SIGNUP_CLOSED"] != "true" || open || c.SetupGate.NativeLock != NativeLockApplied {
t.Fatalf("after: env=%q open=%v native=%q", c.Env["SIGNUP_CLOSED"], open, c.SetupGate.NativeLock)
}
}
+3 -1
View File
@@ -288,6 +288,8 @@ type Manager struct {
// nativeLockBusy: one after_setup run per app at a time (the loop, the window and a press can meet).
nativeLockBusy sync.Map
afterSetupSync bool // tests: run after_setup in the caller
// nativeOpenMissingLogged: apps already logged as "closed by a command, no open_command" (R-717, once each).
nativeOpenMissingLogged sync.Map
// --- guarded update (slice 4, update.go) ---
updateGuards UpdateGuards // init-only, SetUpdateGuards; nil ⇒ every update is REFUSED
@@ -1237,7 +1239,7 @@ func deepCopyStack(s *Stack) Stack {
if s.Meta.AfterSetup != nil {
as := *s.Meta.AfterSetup
as.Env = cloneStrMap(as.Env)
as.Args, as.Command = cloneStrs(as.Args), cloneStrs(as.Command)
as.Args, as.Command, as.OpenCommand = cloneStrs(as.Args), cloneStrs(as.Command), cloneStrs(as.OpenCommand)
cp.Meta.AfterSetup = &as
}
if s.Meta.SetupDoneProbe != nil {
+12 -2
View File
@@ -134,7 +134,9 @@ func (m *Manager) OpenSignupWindow(name string) (string, error) {
if err := m.removeSignupBlockFile(name); err != nil {
return "", err
}
if st.Meta.AfterSetup != nil && len(st.Meta.AfterSetup.Env) > 0 { // v0.282.0: the app's own switch opens too (a restart)
// v0.282.0: the app's own switch opens too (env: a restart). R-717: and the template's open_command; a template
// that closes by command without one is logged once by liftNativeLock.
if as := st.Meta.AfterSetup; as != nil && (len(as.Env) > 0 || len(as.OpenCommand) > 0 || len(as.Command) > 0) {
m.goNativeLock(name, false, "the household's window")
}
m.logger.Printf("[INFO] [stacks] %s: the household opened sign-up until %s — the loop closes it again", name, until)
@@ -188,7 +190,12 @@ func (m *Manager) reconcileSignupBlocks() {
last := st.AppConfig.AfterSetup
due := last == nil || last.OK
if !due {
if t, err := time.Parse(time.RFC3339, last.At); err != nil || m.now().Sub(t) > nativeLockRetry {
// R-717: a switch the window opened (or may have) is retried sooner — sign-up may be open in the app.
gap := nativeLockRetry
if s := st.AppConfig.SetupGate.NativeLock; s == NativeLockLifted || s == NativeLockOpening {
gap = nativeLockOpenRetry
}
if t, err := time.Parse(time.RFC3339, last.At); err != nil || m.now().Sub(t) > gap {
due = true
}
}
@@ -202,6 +209,9 @@ func (m *Manager) reconcileSignupBlocks() {
// nativeLockRetry: how often the loop retries an app's own switch that could not be set.
var nativeLockRetry = 30 * time.Minute
// nativeLockOpenRetry (R-717): how often the loop retries closing a switch the household's window opened.
var nativeLockOpenRetry = 2 * time.Minute
// SetupGateProbe asks the app's own "setup done" status once (the household's button asks it first, Part A of the
// 2026-09-29 afternoon brief). has=false: the template declares no probe.
func (m *Manager) SetupGateProbe(name string) (has bool, done bool, got string, err error) {
+1
View File
@@ -1031,6 +1031,7 @@ func (m *Manager) verifyAndConclude(ctx context.Context, name, dir string, env [
m.clearFailedStep(name, dir) // R-680: and the failed-step record
m.clearJournal(name)
m.removePreUpdateCopies(dir)
m.markNativeLockForReapply(name, dir) // R-717: the loop closes the app's own sign-up switch again after an update
// R-678 (v0.271.0): the app's catalog fields — ladder_steps_left, the badge's inputs, the pin — are
// re-read NOW, before Updating goes false, so neither a person nor the automatic leg ever reads the
// pre-update values after `done`. MEASURED 2026-09-24: ~50 s stale, six re-presses by the caller.
@@ -21,6 +21,11 @@ func TestCloseSignupPage_TheCardAndItsPress(t *testing.T) {
if !strings.Contains(native, "újraindul") || !strings.Contains(native, `id="signup-native-failed"`) {
t.Fatal("the window's restart line or the failed-switch line is missing")
}
// R-717: a failed OPEN says so in its own words — never "could not be closed".
openFailed := renderAppInfoWith(t, map[string]interface{}{"SignupClosed": true, "SignupNativeOpenFailed": true})
if !strings.Contains(openFailed, `id="signup-native-open-failed"`) || !strings.Contains(openFailed, "megnyitni") || strings.Contains(openFailed, `id="signup-native-failed"`) {
t.Fatal("the failed-open line is missing, or the failed-close line shows instead")
}
}
// COMPANION RED-PROOF: skip the CloseSignupNow error mapping → the gated app answers 500, not 409.
+5 -1
View File
@@ -783,7 +783,11 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
data["CloseSignupOffered"] = s.stackMgr.CloseSignupOffered(found.Name)
}
data["SignupNative"] = found.Meta.AfterSetup != nil && len(found.Meta.AfterSetup.Env) > 0
data["SignupNativeFailed"] = found.AppConfig != nil && found.AppConfig.AfterSetup != nil && !found.AppConfig.AfterSetup.OK
nativeFailed := found.AppConfig != nil && found.AppConfig.AfterSetup != nil && !found.AppConfig.AfterSetup.OK
// R-717: a failed OPEN (the household's window; the switch was closed again) says so in its own words.
nativeOpenFailed := nativeFailed && found.AppConfig.AfterSetup.Step == "open"
data["SignupNativeFailed"] = nativeFailed && !nativeOpenFailed
data["SignupNativeOpenFailed"] = nativeOpenFailed
// v0.281.0 (decision 47): the sign-up card — closed, or open for the household's 15 minutes.
if s.stackMgr != nil {
blocked, until := s.stackMgr.SignupBlocked(found.Name)
@@ -408,6 +408,10 @@ func i18nCases() []i18nCase {
d := signupCase("x", true, "").data()
d["SignupNative"], d["SignupNativeFailed"] = true, true
return d
}}, i18nCase{"app_info_signup_native_open_failed", "app_info", func() map[string]interface{} {
d := signupCase("x", true, "").data()
d["SignupNativeOpenFailed"] = true // R-717: the window could not open the app's own switch
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{} {
@@ -130,6 +130,9 @@
{{- if .SignupNativeFailed}}
<p class="form-hint" id="signup-native-failed">{{T "app_info.signup_native_failed"}}</p>
{{- end}}
{{- if .SignupNativeOpenFailed}}
<p class="form-hint" id="signup-native-open-failed">{{T "app_info.signup_native_open_failed"}}</p>
{{- end}}
{{- if .SignupClosed}}
{{- if .SignupNative}}
<p class="form-hint">{{T "app_info.signup_window_restart"}}</p>
@@ -0,0 +1,607 @@
<!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}:{};}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</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-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>
<div class="app-info-card" style="margin-top:1rem" id="signup-card">
<h3>Regisztráció</h3>
<p>Az első admin fiók után a regisztráció zárva: idegen nem hozhat létre fiókot.</p>
<p class="form-hint" id="add-people">Családtagot így adhatsz hozzá: Admin panel → Users.</p>
<p class="form-hint" id="signup-native-open-failed">Az alkalmazás saját regisztrációs kapcsolóját nem sikerült megnyitni a 15 percre, ezért zárva maradt. Új családtag így az alkalmazásban nem tud regisztrálni. Próbáld újra később.</p>
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/opengist/signup-window')">Regisztráció megnyitása 15 percre</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</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>