v0.270.0: no update for a current app (R-679); an interrupted install is reported (R-681); a restore brings back the pinned version's health check (R-669); R-674
gates / gates (push) Successful in 24s

Five red-proofs. MinAgent 0.131.0 unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-24 16:49:43 +02:00
parent 7c6140d95b
commit 504eae018b
23 changed files with 1121 additions and 16 deletions
+23
View File
@@ -1,3 +1,26 @@
## v0.270.0 — no update for a current app; an interrupted install is reported; a restore brings back the right health check (2026-09-24, R-679, R-681, R-669, R-674)
**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged; R-681 rides the existing `app_deploy_failed`).
New strings: yes (hu + en).
- **R-679:** an Update pressed on an app already at the catalog head is refused BEFORE anything moves —
`409 already_current`, „Ez az alkalmazás már a legfrissebb elérhető változatot futtatja — nincs mit
frissíteni." / "This app is already running the newest version available — there is nothing to update."
"Current" is `CatalogOrder`'s verdict, so a re-tested digest for a floating tag still updates. Measured:
navidrome at the head was pressed four times, each a dump, a pull and a restart. (A test comment had called a
same-version Update "the repair path"; Restart is that path.)
- **R-681:** an install cut off by a controller restart is finished through the failure path and REPORTED: an
install marker is written before the compose-up and removed when the install ends; a marker found at start →
`compose down` (volumes kept), the stale pin records cleared, `app_deploy_failed` with the reason, and the
apps page says „A telepítés egy újraindítás miatt félbemaradt…" until the next install. An install that had
finished (its record says deployed) only loses the marker.
- **R-669:** the recovery unit captures the PINNED version's `.felhom.yml` (`applied-meta`), not the stack dir's
file the sync may already have replaced with a newer or failing step's; a restore makes the restored file the
applied record. Measured 2026-09-24: the undo judged the correct old version with the failed step's probe
and held the app.
- **R-674:** the ladder log says "AT THE HEAD" when the pin equals the newest step, not "older than the ladder".
- Red-proofs: five (REPORT).
## v0.269.1 — an installed app keeps the image it runs until an Update moves it (2026-09-24 night, Part B live finding)
**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (as v0.269.0). New strings: none.
+6
View File
@@ -718,6 +718,12 @@ tested digest; an INSTALLED app keeps the digest it runs until a guarded update
over and never renders a newer one (v0.269.1, `CarryDigests`). An update judges the new
version by its own `.felhom.yml` (the step's, or the catalog's). A stranded app's Remove keeps the data.
**No update for a current app; interrupted installs (v0.270.0).** An Update on an app already at the catalog
head answers `409 already_current` and moves nothing (R-679). An install cut off by a controller restart is
finished at the next start: what it started is removed (volumes kept), `app_deploy_failed` is sent, and the
apps page says the install was interrupted until the next install (R-681). The recovery unit keeps the pinned
version's `.felhom.yml`, and a restore makes it the applied record (R-669).
**Start/restart never answer "completed" (v0.263.0, R-642)** — they answer what was requested and the
state the containers are in at that moment; whether the app works is the health probe's to say.
+7
View File
@@ -1715,6 +1715,11 @@ func main() {
notifier.NotifyAppDeployFailed(name, display, detail)
})
}
// R-681 (v0.270.0): an install a restart cut off is finished through the failure path and reported —
// AFTER the deploy-done hook exists, so its event is sent.
if names := stackMgr.RecoverInterruptedInstalls(); len(names) > 0 {
log.Printf("[WARN] [stacks] %d install(s) interrupted by the restart were resolved and reported: %v", len(names), names)
}
stackMgr.RecoverMigration(ctx)
webServer.SetEncryptionKey(encKey)
webServer.SetAppExporter(appExporter)
@@ -2839,6 +2844,8 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin
} else if err := a.mgr.SetPin(name, stackDir, pin, data); err != nil {
log.Printf("[ERROR] [stacks] pin %s: %v", name, err)
}
// R-669 (v0.270.0): the restored .felhom.yml IS the pinned version's record now.
a.mgr.RecordRestoredAppliedMeta(name, stackDir)
return nil
}
@@ -0,0 +1,57 @@
package backup
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-669 (v0.270.0): the recovery unit captures the PINNED version's .felhom.yml (the applied record),
// not the stack dir's — which the sync may already have replaced with a newer, even failing, step's file.
// MEASURED 2026-09-24 (A2): the unit held port 8999 (the failing step's probe), a restore wrote it back,
// and the next undo judged the correct version with it and held the app.
//
// COMPANION RED-PROOF (REPORT.md): capture the stack dir's .felhom.yml again → "the unit carries the
// failing step's probe".
func TestR669_UnitCapturesThePinnedVersionsMeta(t *testing.T) {
tmp := t.TempDir()
stackDir := filepath.Join(tmp, "stack")
drive := filepath.Join(tmp, "drive")
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), "services:\n app:\n image: example/app:1.2.3\n")
mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: Example\nhealthcheck:\n checks:\n - type: api\n port: 8999\n") // flowed by the sync
mustWrite(t, stacks.AppliedMetaFile(stackDir), "display_name: Example\nhealthcheck:\n checks:\n - type: api\n port: 80\n") // the pinned version's
mustWrite(t, filepath.Join(stackDir, "app.yaml"), "deployed: true\nenv: {}\n")
m := &Manager{
logger: log.New(io.Discard, "", 0),
systemDataPath: filepath.Join(tmp, "system"),
stackProvider: &fakeRecoveryProvider{info: RecoveryInfo{StackDir: stackDir, DisplayName: "Example", ImagePins: []string{"example/app:1.2.3"}}, hdd: drive},
version: "vtest",
}
if err := m.CaptureRecoveryUnit("example"); err != nil {
t.Fatalf("capture: %v", err)
}
b, err := os.ReadFile(filepath.Join(RecoveryUnitComposePath(drive, "example"), ".felhom.yml"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "8999") || !strings.Contains(string(b), "port: 80") {
t.Fatalf("the unit carries the failing step's probe, not the pinned version's:\n%s", b)
}
// Control: no applied record (an app pinned before v0.263.2) → the stack dir's file, as before.
if err := os.RemoveAll(filepath.Dir(stacks.AppliedMetaFile(stackDir))); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: Example changed\n") // force a recapture
if err := m.CaptureRecoveryUnit("example"); err != nil {
t.Fatal(err)
}
b, _ = os.ReadFile(filepath.Join(RecoveryUnitComposePath(drive, "example"), ".felhom.yml"))
if !strings.Contains(string(b), "Example changed") {
t.Fatalf("without an applied record the stack dir's file must be captured: %s", b)
}
}
+12 -1
View File
@@ -13,6 +13,7 @@ import (
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gopkg.in/yaml.v3"
)
@@ -115,7 +116,17 @@ func (m *Manager) CaptureRecoveryUnit(stackName string) error {
checksums := make(map[string]string)
var configFiles []string
for _, fname := range []string{"docker-compose.yml", ".felhom.yml"} {
data, err := os.ReadFile(filepath.Join(info.StackDir, fname))
src := filepath.Join(info.StackDir, fname)
// R-669 (v0.270.0): the .felhom.yml that belongs to the PINNED version — the one the compose above
// runs — is the applied record. The stack dir's own file flows from the catalog on every sync and
// can already be a NEWER (even a failing) version's; restored with the unit, it became the pinned
// version's probe and an undo judged the correct version unhealthy (measured 2026-09-24, A2).
if fname == ".felhom.yml" {
if _, err := os.Stat(stacks.AppliedMetaFile(info.StackDir)); err == nil {
src = stacks.AppliedMetaFile(info.StackDir)
}
}
data, err := os.ReadFile(src)
if err != nil {
continue // optional — capture whichever exist
}
+2
View File
@@ -1325,6 +1325,7 @@
"err.stacks.ugyfel_adatok_ellenorzese_sikertelen": "checking your own files failed: %s",
"err.stacks.ugyfel_adatok_osszefesulese_sikertelen": "merging your own files failed: %s",
"err.stacks.ujratelepites_sikertelen": "the reinstall failed (%s): %s",
"err.stacks.update_already_current": "This app is already running the newest version available — there is nothing to update.",
"err.stacks.update_downgrade": "This version is newer than the one in the catalog — moving back needs the operator.",
"err.stacks.update_self_updating": "The controller is updating. Try again in a few minutes.",
"err.stacks.update_undo_copy_failed": "The update did not start, because the copy of the data taken before an update could not be made. The app keeps running on its previous version.",
@@ -2074,6 +2075,7 @@
"stacks.reszletek": "Details",
"stacks.sablonok_frissitese": "↻ Refresh templates",
"stacks.sablonok_frissitese_a_kozponti_katalogus": "Refresh the templates from the central catalog",
"stacks.install_interrupted": "The installation was interrupted by a restart, and the box removed what it had started. Press Install again.",
"stacks.telepites": "Install",
"stacks.telepitheto": "Installable",
"stacks.url_nem_elerheto_utvonal_nincs": "URL not reachable – route not published",
+2
View File
@@ -1316,6 +1316,7 @@
"err.stacks.ugyfel_adatok_ellenorzese_sikertelen": "ügyfél-adatok ellenőrzése sikertelen: %s",
"err.stacks.ugyfel_adatok_osszefesulese_sikertelen": "ügyfél-adatok összefésülése sikertelen: %s",
"err.stacks.ujratelepites_sikertelen": "újratelepítés sikertelen (%s): %s",
"err.stacks.update_already_current": "Ez az alkalmazás már a legfrissebb elérhető változatot futtatja — nincs mit frissíteni.",
"err.stacks.update_downgrade": "Ez a változat újabb a katalógusban lévőnél — visszalépés csak az üzemeltető kérésére.",
"err.stacks.update_self_updating": "A vezérlő éppen frissül. Próbáld újra néhány perc múlva.",
"err.stacks.update_undo_copy_failed": "A frissítés nem indult el, mert a frissítés előtti adatmásolat nem készült el. Az alkalmazás a korábbi verzióval fut tovább.",
@@ -2062,6 +2063,7 @@
"stacks.reszletek": "Részletek",
"stacks.sablonok_frissitese": "↻ Sablonok frissítése",
"stacks.sablonok_frissitese_a_kozponti_katalogus": "Sablonok frissítése a központi katalógusból",
"stacks.install_interrupted": "A telepítés egy újraindítás miatt félbemaradt, és a doboz eltávolította, amit elkezdett. Nyomd meg újra a Telepítés gombot.",
"stacks.telepites": "Telepítés",
"stacks.telepitheto": "Telepíthető",
"stacks.url_nem_elerheto_utvonal_nincs": "URL nem elérhető – útvonal nincs publikálva",
+14
View File
@@ -397,6 +397,16 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
}
m.mu.Unlock()
// R-681: the install's own journal — removed when it ends either way; found at start = interrupted.
if err := markInstallPending(stackDir); err != nil {
m.logger.Printf("[WARN] [stacks] Stack %s: cannot write the install marker (%v) — a restart mid-install would go unreported", req.StackName, err)
}
m.mu.Lock()
if s, ok := m.stacks[req.StackName]; ok {
s.InstallInterrupted = false
}
m.mu.Unlock()
// Run docker compose up -d asynchronously
go m.runComposeDeploy(req.StackName, stackDir, env, appCfg)
@@ -453,6 +463,7 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
// The app.yaml is deliberately NOT deleted here: it is the crash-safe record written with
// Deployed:false, it carries the settings the customer typed, and a redeploy reuses them. The
// state the surfaces read is `not_deployed`, which is the fact that matters.
clearInstallPending(stackDir) // R-681: the install ended (badly) and said so
if m.deployDoneHook != nil {
m.deployDoneHook(name, false, composeErr.Error())
}
@@ -477,9 +488,12 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
s.AppConfig = nil
}
m.mu.Unlock()
clearInstallPending(stackDir)
return
}
clearInstallPending(stackDir) // R-681: the durable record says deployed — the install is over
// Clear deploying flag
m.mu.Lock()
if s, ok := m.stacks[name]; ok {
@@ -0,0 +1,100 @@
package stacks
import (
"os"
"path/filepath"
"time"
)
// ── An install cut off by a controller restart is finished, not forgotten (R-681, v0.270.0) ─────────
//
// MEASURED 2026-09-24 (night, chaos round 2): `systemctl restart docker` 20 s into n8n's install took the
// controller down with it. After the restart the box logged NOTHING about n8n, the app read not_deployed
// and the household's page showed it as never installed — the install had simply vanished, while its
// half-written files stayed in the stack dir. An update journals itself and resumes after the same
// accident; an install did not.
//
// THE MECHANISM: DeployStack writes installPendingFile before the compose-up goroutine starts;
// runComposeDeploy removes it when the install ENDS either way (the failure path already reports itself,
// R-536/R-649). A pending marker found at start therefore means the process died mid-install, and
// RecoverInterruptedInstalls finishes it through the SAME failure path a failed install takes:
// - the durable record says Deployed:true → the install had finished; only the marker goes;
// - otherwise → `compose down` (volumes kept, R-649), the stale pin records cleared, the app reads
// not-installed with installInterruptedFile set (the page's sentence, surviving further restarts
// until the next install), and the deploy-done hook fires `app_deploy_failed` with the reason.
// Pinned by r681_install_interrupted_test.go.
const (
installPendingFile = ".felhom-install-pending"
installInterruptedFile = ".felhom-install-interrupted"
)
// installInterruptedReason is the detail the event and the deploy page carry.
const installInterruptedReason = "interrupted by a controller restart before it finished — install it again"
func markInstallPending(stackDir string) error {
_ = os.Remove(filepath.Join(stackDir, installInterruptedFile)) // a new install supersedes the old sentence
return os.WriteFile(filepath.Join(stackDir, installPendingFile), []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644)
}
func clearInstallPending(stackDir string) {
_ = os.Remove(filepath.Join(stackDir, installPendingFile))
}
func installInterrupted(stackDir string) bool {
_, err := os.Stat(filepath.Join(stackDir, installInterruptedFile))
return err == nil
}
// RecoverInterruptedInstalls runs once at start, after the deploy-done hook is wired. It returns the
// names it resolved as interrupted.
func (m *Manager) RecoverInterruptedInstalls() []string {
m.mu.RLock()
type cand struct{ name, dir string }
var cands []cand
for name, st := range m.stacks {
dir := filepath.Dir(st.ComposePath)
if _, err := os.Stat(filepath.Join(dir, installPendingFile)); err == nil {
cands = append(cands, cand{name, dir})
}
}
m.mu.RUnlock()
var out []string
for _, c := range cands {
if cfg := LoadAppConfig(c.dir); cfg != nil && cfg.Deployed {
clearInstallPending(c.dir)
m.logger.Printf("[INFO] [stacks] install %s: its record says deployed — the install had finished before the restart; marker cleared (R-681)", c.name)
continue
}
m.logger.Printf("[WARN] [stacks] install %s was INTERRUPTED by a controller restart — removing what it started (volumes kept) and reporting it (R-681)", c.name)
down := m.composeDownFn
if down == nil {
down = func(dir string) error { _, err := m.composeExecCustomEnv(dir, m.stackEnv(dir), "down"); return err }
}
if err := down(c.dir); err != nil {
m.logger.Printf("[ERROR] [stacks] install %s: removing what the interrupted install started failed: %v — Remove clears it", c.name, err)
}
for _, p := range []string{AppliedComposePath(c.dir), filepath.Join(c.dir, appliedMetaDir)} {
_ = os.RemoveAll(p) // the pin of a version that never became real
}
if err := os.WriteFile(filepath.Join(c.dir, installInterruptedFile), []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil {
m.logger.Printf("[WARN] [stacks] install %s: cannot record the interruption for the page: %v", c.name, err)
}
clearInstallPending(c.dir)
m.mu.Lock()
if s, ok := m.stacks[c.name]; ok {
s.Deployed = false
s.Deploying = false
s.AppConfig = nil
s.DeployError = installInterruptedReason
s.InstallInterrupted = true
}
m.mu.Unlock()
if m.deployDoneHook != nil {
m.deployDoneHook(c.name, false, installInterruptedReason)
}
out = append(out, c.name)
}
return out
}
+7
View File
@@ -136,6 +136,13 @@ func nextLadderStep(templateDir string, pinned map[string]string) (LadderStep, e
}
}
if idx < 0 {
// R-674 (v0.270.0): a pin equal to the newest entry's `to` is AT THE HEAD, not "older than the
// ladder" — the old sentence sent an operator looking for a missing record. Pinned by
// TestR674_HeadIsNotCalledOlder.
if sameRefs(ladder[len(ladder)-1].To, pinned) {
return LadderStep{Index: -1, Source: current, Meta: currentMeta,
Why: fmt.Sprintf("the installed version %s is AT THE HEAD of the update_ladder (%d entries) — the catalog's current definition", summarisePin(pinned), len(ladder))}, nil
}
return LadderStep{Index: -1, Source: current, Meta: currentMeta,
Why: fmt.Sprintf("the installed version %s matches no update_ladder entry (%d entries) — an app older than the ladder has no record to climb; the catalog's current definition", summarisePin(pinned), len(ladder))}, nil
}
+21 -13
View File
@@ -148,6 +148,9 @@ type Stack struct {
AppConfig *AppConfig `json:"app_config,omitempty"`
Deploying bool `json:"deploying"` // compose up in progress
DeployError string `json:"deploy_error,omitempty"` // last async deploy error
// InstallInterrupted (R-681, v0.270.0): the last install was cut off by a controller restart; the
// page says so until the next install (read from the stack dir's marker at every scan).
InstallInterrupted bool `json:"install_interrupted,omitempty"`
// Updating / UpdatePhase / UpdatePhaseLabel / UpdateError (update arc slice 4, v0.237.0) are the
// guarded update's in-memory progress, the same shape as Deploying/DeployError: the API answers
// 202 at once and the page polls GET /api/stacks/{name}. See update.go.
@@ -267,7 +270,10 @@ type Manager struct {
// --- guarded update (slice 4, update.go) ---
updateGuards UpdateGuards // init-only, SetUpdateGuards; nil ⇒ every update is REFUSED
updateComposeFn func(dir string, env []string, args ...string) (string, error)
updateHealthFn func(ctx context.Context, name string, timeout time.Duration) (bool, string)
// composeDownFn is RecoverInterruptedInstalls' seam (R-681): nil → the real `compose down`. Tests set a
// fake so they never reach real Docker (R-650).
composeDownFn func(dir string) error
updateHealthFn func(ctx context.Context, name string, timeout time.Duration) (bool, string)
// v0.263.0 undo seams (undo.go): the volume copier (nil ⇒ docker) and the undo's health wait,
// which receives the probe it must use (nil ⇒ waitUpdateHealthyMeta).
undoCopier volumeCopier
@@ -654,21 +660,23 @@ func (m *Manager) ScanStacks() error {
existing.CatalogImages = catImages
existing.LadderStepsLeft = stepsLeft
existing.CatalogDigests, existing.CatalogTestedAt = catDigests, catTestedAt
existing.InstallInterrupted = !deployed && installInterrupted(stackDir) // R-681
}
} else {
m.stacks[name] = &Stack{
Name: name,
Meta: meta,
ComposePath: composePath,
State: StateNotDeployed,
Deployed: deployed,
Protected: m.cfg.IsProtectedStack(name),
AppConfig: appCfg,
TemplateImages: tplImages,
CatalogImages: catImages,
LadderStepsLeft: stepsLeft,
CatalogDigests: catDigests,
CatalogTestedAt: catTestedAt,
Name: name,
Meta: meta,
ComposePath: composePath,
State: StateNotDeployed,
Deployed: deployed,
Protected: m.cfg.IsProtectedStack(name),
AppConfig: appCfg,
TemplateImages: tplImages,
CatalogImages: catImages,
LadderStepsLeft: stepsLeft,
InstallInterrupted: !deployed && installInterrupted(stackDir), // R-681
CatalogDigests: catDigests,
CatalogTestedAt: catTestedAt,
}
}
}
@@ -0,0 +1,33 @@
package stacks
import (
"os"
"path/filepath"
"strings"
"testing"
)
// R-669 (v0.270.0): after a restore writes the app's .felhom.yml, that file IS the pinned version's
// record — so the NEXT update's undo judges the old version with the restored probe, not the failed
// step's. Asserted on what the undo actually reads: savePreUpdateMeta's copy.
//
// COMPANION RED-PROOF (REPORT.md): make RecordRestoredAppliedMeta a no-op (v0.269.1: no restore path
// rewrote applied-meta) → "the next undo would judge with the failed step's probe".
func TestR669_RestoreResetsTheAppliedRecord(t *testing.T) {
m, _, _, _ := newSlice4Manager(t)
dir := filepath.Dir(m.stacks["nextcloud"].ComposePath)
if err := os.MkdirAll(filepath.Dir(AppliedMetaFile(dir)), 0o755); err != nil {
t.Fatal(err)
}
mustWrite(t, AppliedMetaFile(dir), "healthcheck:\n checks:\n - type: api\n port: 8999\n") // left by the failed step
mustWrite(t, filepath.Join(dir, ".felhom.yml"), "healthcheck:\n checks:\n - type: api\n port: 80\n") // written by the restore
m.RecordRestoredAppliedMeta("nextcloud", dir)
md, err := savePreUpdateMeta(dir)
if err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(filepath.Join(md, ".felhom.yml"))
if strings.Contains(string(b), "8999") || !strings.Contains(string(b), "port: 80") {
t.Fatalf("the next undo would judge with the failed step's probe:\n%s", b)
}
}
@@ -0,0 +1,30 @@
package stacks
import (
"path/filepath"
"strings"
"testing"
)
// R-674 (v0.270.0): a pin equal to the ladder's newest `to` is AT THE HEAD — the log must not call it
// "older than the ladder".
//
// COMPANION RED-PROOF (REPORT.md): drop the head branch in nextLadderStep → "the head was called older
// than the ladder".
func TestR674_HeadIsNotCalledOlder(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, ".felhom.yml"), "display_name: X\nupdate_ladder:\n"+ladderLine(ladderA, ladderB)+ladderLine(ladderB, ladderC))
mustWrite(t, filepath.Join(dir, "docker-compose.yml"), "services:\n web:\n image: "+ladderC+"\n")
step, err := nextLadderStep(dir, map[string]string{"web": ladderC})
if err != nil {
t.Fatal(err)
}
if strings.Contains(step.Why, "older than the ladder") || !strings.Contains(step.Why, "AT THE HEAD") {
t.Fatalf("the head was called older than the ladder: %q", step.Why)
}
// Control: a pin the ladder does not know is still said so.
step, _ = nextLadderStep(dir, map[string]string{"web": "nextcloud:30.0.0-apache"})
if !strings.Contains(step.Why, "matches no update_ladder entry") {
t.Fatalf("the unknown-pin sentence changed: %q", step.Why)
}
}
@@ -0,0 +1,48 @@
package stacks
import (
"strings"
"testing"
"time"
)
// R-679 (v0.270.0): an Update pressed on an app already at the catalog head is refused BEFORE anything
// moves — no backup, no pull, no restart — with its own reason and a sentence in the household's
// language. A re-tested digest for the same tag is NOT current and still updates.
//
// COMPANION RED-PROOF (REPORT.md): drop the UpdateOrderCurrent check from UpdatePreflight → "an app at
// the head was allowed to update".
func TestR679_CurrentAppIsRefused(t *testing.T) {
m, _, _, _ := newSlice4Manager(t)
st := m.stacks["nextcloud"]
st.AppConfig.InstalledImages = map[string]InstalledImage{"web": oi("nextcloud:31.0.14-apache")}
st.CatalogImages = map[string]string{"web": "nextcloud:31.0.14-apache"}
ref := m.UpdatePreflight("nextcloud")
if ref == nil {
t.Fatal("an app at the head was allowed to update")
}
if ref.Reason != "already_current" || ref.Cause == nil || !strings.Contains(ref.Message, "legfrissebb") {
t.Fatalf("refusal = %q %q (cause %v) — want already_current, a key-bearing Cause, the Hungarian sentence", ref.Reason, ref.Message, ref.Cause)
}
}
func TestR679_BehindAndRetestedDigestStillUpdate(t *testing.T) {
m, _, _, _ := newSlice4Manager(t)
st := m.stacks["nextcloud"]
st.AppConfig.InstalledImages = map[string]InstalledImage{"web": oi("nextcloud:31.0.13-apache")}
st.CatalogImages = map[string]string{"web": "nextcloud:31.0.14-apache"}
if ref := m.UpdatePreflight("nextcloud"); ref != nil {
t.Fatalf("a behind app was refused: %q", ref.Reason)
}
// same tag, a newer TESTED digest than the install → behind, not current
inst := oi("redis:7-alpine")
inst.Digest = "sha256:" + strings.Repeat("a", 64)
inst.At = time.Now().Add(-48 * time.Hour).UTC().Format(time.RFC3339)
st.AppConfig.InstalledImages = map[string]InstalledImage{"web": inst}
st.CatalogImages = map[string]string{"web": "redis:7-alpine"}
st.CatalogDigests = map[string]string{"web": "sha256:" + strings.Repeat("b", 64)}
st.CatalogTestedAt = time.Now()
if ref := m.UpdatePreflight("nextcloud"); ref != nil {
t.Fatalf("a re-tested digest was refused as current: %q", ref.Reason)
}
}
@@ -0,0 +1,69 @@
package stacks
import (
"os"
"path/filepath"
"testing"
)
// R-681 (v0.270.0): an install a controller restart cut off is FINISHED through the failure path and
// REPORTED — never silent. The consequences asserted: `compose down` ran (never real Docker — a fake), the
// household is told (the deploy-done hook fires as a failure with the reason), the stale pin records are
// gone, and the page's flag survives a rescan.
//
// COMPANION RED-PROOF (REPORT.md): make RecoverInterruptedInstalls return without acting (v0.269.1: no
// install marker, nothing looked) → "an interrupted install was not reported".
func TestR681_InterruptedInstallIsFinishedAndReported(t *testing.T) {
m, dir := newPinManager(t, pinTplOld, pinTplOld, "deployed: false\nenv: {}\n")
m.stacks["nextcloud"].Deployed = false
mustWrite(t, filepath.Join(dir, installPendingFile), "2026-09-24T11:48:42Z\n")
mustWrite(t, AppliedComposePath(dir), pinTplOld) // stale, from an earlier install
var downs []string
m.composeDownFn = func(d string) error { downs = append(downs, d); return nil }
type call struct {
name string
ok bool
detail string
}
var hooks []call
m.SetDeployDoneHook(func(n string, ok bool, d string) { hooks = append(hooks, call{n, ok, d}) })
got := m.RecoverInterruptedInstalls()
if len(got) != 1 || len(hooks) != 1 || hooks[0].ok || hooks[0].detail != installInterruptedReason {
t.Fatalf("an interrupted install was not reported: resolved=%v hooks=%+v", got, hooks)
}
if len(downs) != 1 || downs[0] != dir {
t.Fatalf("what the interrupted install started was not removed: downs=%v", downs)
}
if _, err := os.Stat(AppliedComposePath(dir)); err == nil {
t.Fatal("the stale pin record of a version that never ran is still there")
}
if _, err := os.Stat(filepath.Join(dir, installPendingFile)); err == nil {
t.Fatal("the pending marker survived — the next start would report it again")
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
if st, _ := m.GetStack("nextcloud"); !st.InstallInterrupted || st.Deployed {
t.Fatalf("after a rescan: interrupted=%v deployed=%v — the page must still say it", st.InstallInterrupted, st.Deployed)
}
// A second start finds nothing to report.
if again := m.RecoverInterruptedInstalls(); len(again) != 0 || len(hooks) != 1 {
t.Fatalf("reported twice: %v", again)
}
}
// An install that FINISHED before the restart (its record says deployed) is left alone: no down, no event.
func TestR681_FinishedInstallIsLeftAlone(t *testing.T) {
m, dir := newPinManager(t, pinTplOld, pinTplOld, "deployed: true\nenv: {}\n")
mustWrite(t, filepath.Join(dir, installPendingFile), "x\n")
downs, hooks := 0, 0
m.composeDownFn = func(string) error { downs++; return nil }
m.SetDeployDoneHook(func(string, bool, string) { hooks++ })
if got := m.RecoverInterruptedInstalls(); len(got) != 0 || downs != 0 || hooks != 0 {
t.Fatalf("a finished install was treated as interrupted: resolved=%v downs=%d hooks=%d", got, downs, hooks)
}
if _, err := os.Stat(filepath.Join(dir, installPendingFile)); err == nil {
t.Fatal("the marker of a finished install was not cleared")
}
}
+12
View File
@@ -86,6 +86,18 @@ const preUpdateMetaDir = "pre-update-meta"
// time, got the new probe (romm :8999), and judged the serving old version "did not start".
const appliedMetaDir = "applied-meta"
// AppliedMetaFile is the pinned version's .felhom.yml record (R-669, v0.270.0: the recovery unit captures
// it, so a restore brings back the PINNED version's health check, not the sync's newer one).
func AppliedMetaFile(stackDir string) string { return filepath.Join(stackDir, appliedMetaDir, ".felhom.yml") }
// RecordRestoredAppliedMeta makes the .felhom.yml a restore just wrote the pinned version's record
// (R-669, v0.270.0). MEASURED 2026-09-24 (A2): after a failed step ended in a restore, applied-meta still
// held the FAILED step's probe, and the next failed update's undo judged the correct old version with it
// and held the app. Pinned by TestR669_RestoreResetsTheAppliedRecord.
func (m *Manager) RecordRestoredAppliedMeta(name, stackDir string) {
m.storeAppliedMetaFrom(name, stackDir, filepath.Join(stackDir, ".felhom.yml"))
}
// storeAppliedMeta records the .felhom.yml of the version just pinned. A failure is logged by the
// caller and never fails the act — without it the undo falls back to the current file, loudly.
func storeAppliedMeta(stackDir string, src []byte) error {
+14
View File
@@ -416,6 +416,20 @@ func (m *Manager) UpdatePreflight(name string) *UpdateRefusal {
fmt.Sprintf("installed is provably NEWER than the catalog on every differing service (installed=%v catalog=%v)",
st.AppConfig.InstalledImages, st.CatalogImages))
}
// R-679 (v0.270.0) — AN APP ALREADY AT THE HEAD IS NOT UPDATED. MEASURED 2026-09-24 (night, Part C):
// navidrome at the catalog head was pressed four times; each press made a safety dump, pulled and
// restarted the app (8.5 s of downtime) and changed nothing. "Current" is CatalogOrder's own verdict:
// the installed images equal the catalog's AND no newer tested digest exists for a floating tag — so a
// re-tested digest still updates. Unknown / unorderable / behind fall through as before. Before this, a
// same-version Update was called "the repair path" in a test comment; Restart is the repair path now.
//
// COMPANION RED-PROOF (REPORT.md): drop this check → TestR679_CurrentAppIsRefused fails at "an app at
// the head was allowed to update".
if CatalogOrder(*st) == UpdateOrderCurrent {
return m.refuseUpdateErr(name, "already_current", util.MsgError("err.stacks.update_already_current"),
fmt.Sprintf("installed equals the catalog head on every service and no newer tested digest (installed=%v catalog=%v)",
st.AppConfig.InstalledImages, st.CatalogImages))
}
// R-475: any tier counts, and an app with no copy at all is backed up first by the job. So the only
// refusal left here is Scenario L — no copy on any tier AND no way to make one now. (With a copy
// but no way to back up, the job still applies the age rule and refuses then if the copy is stale.)
@@ -202,7 +202,6 @@ func TestR524_PreflightRefusesDowngrade(t *testing.T) {
}{
{"ahead — the downgrade is refused", "nextcloud:31.0.15-apache", "nextcloud:31.0.14-apache", "downgrade"},
{"behind — the ordinary update is allowed", "nextcloud:31.0.13-apache", "nextcloud:31.0.14-apache", ""},
{"level — allowed (a same-version update is the repair path)", "nextcloud:31.0.14-apache", "nextcloud:31.0.14-apache", ""},
{"unorderable — allowed, exactly as before v0.260.0", "nextcloud:31-apache", "nextcloud:30-apache", ""},
}
for _, c := range cases {
@@ -299,6 +299,19 @@ func i18nCases() []i18nCase {
d["StorageLabels"] = map[string]string{}
return d
}})
// v0.270.0 (R-681): an app whose install a restart cut off — its sentence beside Install, captured when born.
base = append(base, i18nCase{"stacks_install_interrupted", "stacks", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Alkalmazások")
st := i18nStack("n8n", "n8n", stacks.StateNotDeployed, false)
st.InstallInterrupted = true
d["Stacks"] = []stacks.Stack{st}
d["Subdomains"] = map[string]string{}
d["MissingStorage"] = map[string]string{}
d["NetworkStubs"] = map[string]string{}
d["NetworkWarnings"] = map[string]string{}
d["StorageLabels"] = map[string]string{}
return d
}})
// v0.268.0 (`09` §3 decision 14): the ladder's "steps remaining" line, captured when it was born.
base = append(base, i18nCase{"app_info_ladder_steps", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "RomM")
@@ -0,0 +1,33 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-681 (v0.270.0) on the page: an app whose install a restart cut off says so, in both languages, beside
// its Install button — and an ordinary not-installed app shows nothing (negative control).
//
// COMPANION RED-PROOF (REPORT.md): drop the {{if .InstallInterrupted}} line from stacks.html → "the apps
// page is silent about an interrupted install".
func TestR681_PageSaysTheInstallWasInterrupted(t *testing.T) {
st := ubStack(nil, map[string]string{"web": "nginx:1.27"}, "2026-07-18")
st.Name = "n8n"
st.Deployed = false
st.State = stacks.StateNotDeployed
st.InstallInterrupted = true
hu := renderBackupPageLang(t, "hu", "stacks", ubStacksData(st))
en := renderBackupPageLang(t, "en", "stacks", ubStacksData(st))
if !strings.Contains(hu, `data-install-interrupted="true"`) || !strings.Contains(hu, "lbemaradt") {
t.Fatal("the apps page is silent about an interrupted install (hu)")
}
if !strings.Contains(en, `data-install-interrupted="true"`) || !strings.Contains(en, "interrupted by a restart") {
t.Fatal("the apps page is silent about an interrupted install (en)")
}
st.InstallInterrupted = false
if h := renderBackupPageLang(t, "hu", "stacks", ubStacksData(st)); strings.Contains(h, `data-install-interrupted`) || strings.Contains(h, "lbemaradt") {
t.Fatal("an ordinary not-installed app must not carry the sentence")
}
}
@@ -83,7 +83,7 @@
{{else if not .Deployed}}
{{/* The endpoint refuses a non-installable template server-side; hiding the button
here keeps the two consistent rather than offering an action that will fail. */}}
{{if canInstall .Meta}}<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">{{T "stacks.telepites"}}</a>{{end}}
{{if .InstallInterrupted}}<div class="alert alert-warning" data-install-interrupted="true">{{T "stacks.install_interrupted"}}</div>{{end}}{{if canInstall .Meta}}<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">{{T "stacks.telepites"}}</a>{{end}}
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">{{T "stacks.reszletek"}}</a>
{{else}}
{{/* Slice 4 (v0.238.0): the UPDATING and HELD checks come BEFORE isOperational. That
@@ -0,0 +1,616 @@
<!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>Alkalmazások — 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>Debug</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">
<h2>Alkalmazások</h2>
<span class="domain-badge">example.hu</span>
<a href="/import" class="btn btn-sm btn-outline" title="Alkalmazás visszaállítása exportált csomagból">Importálás</a>
<button class="btn btn-sm btn-outline" id="sync-btn" onclick="syncTemplates()" title="Sablonok frissítése a központi katalógusból">↻ Sablonok frissítése</button>
</div>
<div id="sync-toast" class="sync-toast" style="display:none"></div>
<div class="filter-bar" id="filter-bar">
<button class="filter-btn active" data-filter="all">Mind <span class="filter-count" id="count-all"></span></button>
<button class="filter-btn" data-filter="running">Futó <span class="filter-count" id="count-running"></span></button>
<button class="filter-btn" data-filter="stopped">Leállítva <span class="filter-count" id="count-stopped"></span></button>
<button class="filter-btn" data-filter="available">Telepíthető <span class="filter-count" id="count-available"></span></button>
</div>
<div class="stack-grid">
<div class="stack-detail-card stack-state-off" data-filter-state="available" data-href="/apps/n8n">
<div class="stack-detail-header">
<div class="stack-title-row">
<img class="stack-logo-lg" src="/static/assets/n8n-logo.svg" alt="" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/n8n-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>
<h3>n8n</h3>
</div>
</div>
<span class="tag tag-off"><span class="dot"></span>Nincs telepítve</span>
</div>
<p class="stack-detail-desc">Egy teszt alkalmazás leírása</p>
<div class="metarows">
<span class="metarow"><svg class="ico"><use href="#i-memory-stick"/></svg><span class="mono">~128M</span></span>
<span class="metarow"><svg class="ico"><use href="#i-check"/></svg>Pi kompatibilis</span>
<span class="metarow"><svg class="ico"><use href="#i-hard-drive"/></svg>HDD szükséges</span>
<span class="metarow"><svg class="ico"><use href="#i-check"/></svg>Magyar felület</span>
</div>
<div class="stack-detail-actions">
<div class="alert alert-warning" data-install-interrupted="true">A telepítés egy újraindítás miatt félbemaradt, és a doboz eltávolította, amit elkezdett. Nyomd meg újra a Telepítés gombot.</div><a href="/stacks/n8n/deploy" class="btn btn-primary" onclick="return checkBeforeDeploy(event, 'n8n')">Telepítés</a>
<a href="/apps/n8n" class="btn btn-outline">Részletek</a>
</div>
</div>
</div>
<script>
(function() {
var cards = document.querySelectorAll('.stack-detail-card[data-filter-state]');
var counts = {all: cards.length, running: 0, stopped: 0, available: 0};
cards.forEach(function(card) {
var cat = card.getAttribute('data-filter-state');
if (counts[cat] !== undefined) counts[cat]++;
});
['all', 'running', 'stopped', 'available'].forEach(function(f) {
var el = document.getElementById('count-' + f);
if (el) el.textContent = '(' + counts[f] + ')';
});
function applyFilter(filter) {
cards.forEach(function(card) {
if (filter === 'all' || card.getAttribute('data-filter-state') === filter) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
document.querySelectorAll('.filter-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-filter') === filter);
});
var url = new URL(window.location);
if (filter === 'all') {
url.searchParams.delete('filter');
} else {
url.searchParams.set('filter', filter);
}
history.replaceState(null, '', url);
}
document.querySelectorAll('.filter-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
applyFilter(this.getAttribute('data-filter'));
});
});
var urlFilter = new URLSearchParams(window.location.search).get('filter');
if (urlFilter && ['running', 'stopped', 'available'].indexOf(urlFilter) !== -1) {
applyFilter(urlFilter);
}
})();
</script>
</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>';
}
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>';
}
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íti az alkalmazást, az adatokat újra importálnia 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>';
}
}
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.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>
+1
View File
@@ -32,6 +32,7 @@
"update.phase.copying": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.",
"update.phase.undoing": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.",
"update.phase.undone": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.",
"err.stacks.update_already_current": "BORN AS A KEY, v0.270.0 (R-679) -- the already-at-the-head refusal. Pinned by TestR679_CurrentAppIsRefused.",
"err.stacks.update_downgrade": "BORN AS A KEY, v0.260.0 (R-524) -- the downgrade refusal. A NEW sentence, never a Go literal. Pinned by TestR524_PreflightRefusesDowngrade, which asserts the refusal carries a Cause so api.Router.errText can render it.",
"err.stacks.update_self_updating": "BORN AS A KEY, v0.261.0 (R-608) -- the app update refuses to start while the controller is swapping itself. A NEW sentence, never a Go literal. Pinned by TestR608_PreflightRefusesWhileTheControllerSwaps, which asserts the refusal carries a Cause so errText can render it.",
"func.state.degraded": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",