controller v0.267.0: tests off DooPlex's Docker, cut-off copies refused, two pages true
gates / gates (push) Successful in 26s
gates / gates (push) Successful in 26s
R-650: internal/dockerexec — every docker exec routed through it; under go test a real docker is refused (opt-in FELHOM_TEST_REAL_DOCKER=1; a stub under the temp dir is allowed). api/stacks/web tests run under a silent stub (TestMain). TestR650_NoBareDockerExec pins it repo-wide. R-640: a dump without its engine's completion marker is refused before the first mutation (unit + off-site restore) and again before any load. R-499: the Tier-2 page's system-disk sentence has four true branches. R-518: the backup button states the measured ~8 min stop. R-626: measured on 9202, not reproduced. 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:
@@ -1,3 +1,26 @@
|
||||
## v0.267.0 — tests never touch DooPlex's Docker, a cut-off copy is never loaded, two pages tell the truth (2026-09-23, R-650, R-640, R-499, R-518; R-626 measured)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged). No hub change. New strings: yes (hu + en).
|
||||
|
||||
- **R-650 — a unit test can no longer act on production Docker.** New `internal/dockerexec`: every docker
|
||||
exec in the controller goes through it, and under `go test` a real docker is refused with an error
|
||||
naming the command (opt-in `FELHOM_TEST_REAL_DOCKER=1`; a stub under the temp dir is allowed). The sweep
|
||||
found **8 `api` tests** and the `stacks`/`web` fixtures running real `docker ps` / `docker compose
|
||||
version` on DooPlex, and one `stacks` test reaching a real `docker-compose down`; those packages now run
|
||||
under a silent stub (`RunWithStub`). `backup`, `appexport`, `system` tests read `docker ps`/`info` and
|
||||
pass on the refusal. `TestR650_NoBareDockerExec` pins the invariant repo-wide.
|
||||
- **R-640 — a cut-off database copy is refused before anything is touched.** `appbackup.CheckDumpComplete`
|
||||
reads the end of the copy for the engine's completion marker. The unit restore and the off-site
|
||||
restore refuse before the first mutation („…adatbázis-másolata csonka… nem indult el”); every replay
|
||||
checks again right before the load, whatever the import seam is.
|
||||
- **R-499 — the system-disk sentence says where THIS box's whole-system backup goes.** Four branches
|
||||
(own drive / same disk / drive gone / cannot ask); „(PBS)” and „nincs külön teendő” only where true.
|
||||
- **R-518 — the backup button states the measured stop** (about 8 minutes on a 12-app box). The brief's
|
||||
„csak néhány másodpercre” was already gone since v0.243.0; the vague „általában néhány perc” is replaced.
|
||||
- **R-626 measured, not reproduced:** remove → 390 s of `docker events` (the remove's destroy seen, no
|
||||
create) + a controller restart + a guest reboot → no container, no volume.
|
||||
- Red-proofs: eight, each seen failing (REPORT).
|
||||
|
||||
## v0.266.0 — a failed install removes what it started (2026-09-23, R-649)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged). No new strings. No hub change.
|
||||
|
||||
+8
-1
@@ -7,7 +7,14 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-09-23 (v0.265.0 — R-634's cause fixed, held badge, OOM storm)
|
||||
Last updated: 2026-09-23 (v0.267.0 — tests off DooPlex's Docker, cut-off copies refused)
|
||||
|
||||
> **2026-09-23 (night) — v0.267.0 (R-650, R-640, R-499, R-518).** Every docker exec goes through
|
||||
> `internal/dockerexec`; under `go test` a real docker is refused (opt-in `FELHOM_TEST_REAL_DOCKER=1`; a
|
||||
> stub under `os.TempDir()` passes). `api`/`stacks`/`web` tests run under `RunWithStub` (TestMain). Restore:
|
||||
> `appbackup.CheckDumpComplete` — a dump without its engine's end marker is refused before the first
|
||||
> mutation (unit + off-site) and again before any load. Tier-2 page: `systemBackupFact` → four sentences.
|
||||
> Backup button: the measured ~8 min. R-626 not reproduced (closed by measurement).
|
||||
|
||||
> **2026-09-23 (late evening) — v0.265.0 (R-634, R-625, R-636, R-647).** A whole-box backup no longer
|
||||
> stops/restarts a DEPLOYING app (`ListDeployedStacks` skips `Deploying`; the volume leg re-asks via the
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
# ADDENDUM — v0.266.0: a failed install removes what it started (R-649, operator ruling, 2026-09-23)
|
||||
# REPORT — controller v0.267.0: tests off DooPlex's Docker, cut-off copies refused, two pages true (2026-09-23)
|
||||
|
||||
`964ae75`. MinAgent 0.131.0. `compose down` (volumes kept) on the deploy's failure branch. Red-proof seen
|
||||
failing; live on 9202: 0 containers left, volumes kept. Floor 0.266.0. Evidence:
|
||||
`felhom.eu/documentation/audits/r649-2026-09-23/`.
|
||||
|
||||
---
|
||||
|
||||
# REPORT — controller v0.265.0: R-634's cause fixed, held apps say so, the OOM storm (2026-09-23)
|
||||
|
||||
R-634, R-625, R-636, R-647. Commit `0054d4b`. MinAgent 0.131.0 (unchanged). Needs hub v0.121.0 (deployed
|
||||
first). **Floor raised to 0.265.0.** Full report, the brief's wrong claims, evidence:
|
||||
`felhom.eu/REPORT.md`, `felhom.eu/documentation/audits/cleanup-2026-09-23/`.
|
||||
Night shift 2026-09-23, Part A. R-650, R-640, R-499, R-518; R-626 measured. MinAgent 0.131.0 (unchanged).
|
||||
No hub change. Full night record: `felhom.eu/documentation/audits/DRILL-night-2026-09-23.md`; evidence
|
||||
`felhom.eu/documentation/audits/night-2026-09-23/A*`.
|
||||
|
||||
## Not done, or changed
|
||||
- R-634: the backup race is fixed; the deploy's OWN failure leaving containers is an operator question → R-649.
|
||||
- R-625: the Update button was already hidden on the list; the fix is the badge (both hold kinds).
|
||||
- R-636: the flag cannot count (sticky) — the kernel `oom_kill` counter is read instead.
|
||||
- My first test draft ran real docker on DooPlex (an empty volume) — removed; tests use seams; R-650.
|
||||
- **R-518:** the brief said the page promises „csak néhány másodpercre". It does not — v0.243.0 removed
|
||||
that. The remaining vague „általában néhány perc" is replaced by the measured ~8 minutes.
|
||||
- **R-626:** not reproduced, so not "fixed" — closed by measurement (below).
|
||||
- R-640's "narrowed" scope held: the undo copies folders; only the three restore paths load dumps.
|
||||
|
||||
## What shipped
|
||||
- **R-634:** deploying apps leave `ListDeployedStacks`; the volume leg re-asks (`deployingReporter`) and
|
||||
SKIPs; `StopStack`/`StartStack` → `ErrStackDeploying`.
|
||||
- **R-625:** badge `badge.update.held` „Megállítva — visszaállítás szükséges" / "Stopped — restore needed".
|
||||
- **R-636:** `ScanOOMKilled` reads `oom_kill`/`memory.max`/`memory.peak`; ≥20 kills in 30 min per container
|
||||
run → one `app_oom_storm` (error, operator-only).
|
||||
- **R-647:** `readerFuncs` (every language) + `UpdateErrorKeyHeld`; `copy_holds` as a key; two log wordings.
|
||||
- **R-650:** `internal/dockerexec` (`Command`/`CommandContext`/`RunWithStub`). 77 docker exec sites routed
|
||||
through it. The sweep (trace of the guard's refusals, `A1-r650-sweep.txt`): `web` 67× `docker compose
|
||||
version`, 19× `docker ps`, 19× `docker info`, 10× `docker inspect felhom-samba`, 2× `docker exec
|
||||
felhom-samba`; `backup` 46× `docker ps`; `stacks` 11× `compose ps`, 4× `docker ps`, **1× `docker-compose
|
||||
down`**; `appexport` 7× `docker ps`; `system` 1× `docker info`; 8 `api` tests FAILED on the refusal
|
||||
(they built a real Manager). `api`/`stacks`/`web` → `TestMain` + `RunWithStub`; the rest pass on the refusal.
|
||||
- **R-640:** `appbackup.CheckDumpComplete`; `incompleteDumps` gate in `RestoreFromRecoveryUnit` and
|
||||
`ReconstituteFromOffsite` before the first mutation; a second check in `reimportDBDumpsFrom` before any
|
||||
load. Test fixtures' fake dumps now end with the real markers (they did not — 38 tests red first).
|
||||
- **R-499:** `systemBackupFact(resolveBackupTargetState)` → four branches on the Tier-2 page.
|
||||
- **R-518:** two bundle strings, both languages, state the measured stop.
|
||||
|
||||
## Red-proofs (8 here, each seen failing)
|
||||
Backup skip; StopStack guard; held badge (v0.264.0 shape); reader funcs; copy_holds phrase; health
|
||||
severity; storm escalation; counter read. Messages in `felhom.eu/REPORT.md` §3.
|
||||
## Red-proofs (8, each seen failing, then restored; `git diff` clean on the reverted file)
|
||||
1. R-650 guard disabled → `TestR650_RealDockerIsRefusedUnderGoTest`: `got <nil>`.
|
||||
2. R-650 one bare `exec.CommandContext(ctx, "docker"…)` put back in dbdump.go → the sweep names `dbdump.go:140`.
|
||||
3. R-640 replay check removed → `a cut-off copy was LOADED`.
|
||||
4. R-640 unit gate removed → `stopped=true calls=[stop recreate startsvc:immich-postgres start]`.
|
||||
5. R-640 off-site gate removed → the replay check still caught it, then a rollback — not the clean refusal.
|
||||
6. R-499 handler not passing the fact → three branches missing on the page.
|
||||
7. R-499 old sentence in the same-disk branch → `promises the backup protects this app`.
|
||||
8. R-518 bundles back to v0.243.0 → measured downtime appears 0 times, old wording present.
|
||||
|
||||
## Live (9202)
|
||||
R-634 race run across a live deploy — the backup stopped three other apps and never the deploying one;
|
||||
held badge + no button + 409 in all four box/reader language pairs; RomM storm at 21 kills, one line at 49.
|
||||
## R-626, measured on 9202 (v0.266.0)
|
||||
navidrome deployed and removed through the product; `docker events` for its compose project 390 s:
|
||||
kill/stop/die/destroy seen (the positive observable), **no create**. Controller restarted at +150 s;
|
||||
guest rebooted after. 0 containers, 0 volumes at every check. Leftover: `applied-compose.yml` +
|
||||
`applied-meta/` stay in the stack dir after a remove (row filed).
|
||||
|
||||
`go test ./...` rc=0; `controller_gates.py` rc=0.
|
||||
## Gates
|
||||
`go build/vet` rc 0; `go test -count=1 ./...` rc 0; `controller_gates.py` all OK (golden-notice advisory).
|
||||
|
||||
@@ -1438,6 +1438,7 @@ Three guards added on the off-site restore surface, all server-side:
|
||||
| **Free space, R-357** | `ReconstituteFromOffsite`, before `mapOffsiteRestorePaths` / `writeSafetyDump` / `StopStack` | the live namespace has less free than the scratch's size. Same wording as the two non-destructive gates (`offsiteNoSpaceMsgFmt`). No headroom multiplier — this copies a measured tree, not a predicted download. **Fail-closed** when either probe reads ≤ 0. The app is never stopped for a refused restore. |
|
||||
| **Incomplete scratch, R-358** | `offboxPlaceHandler` AND `offboxReconstituteHandler` | `OffboxFullScratchReady` is false — no `.felhom-restore-complete.json`, unreadable, wrong schema, or `full:false`. „A visszaállítási másolat nem teljes…". The wizard flags control a button; these control the operation. |
|
||||
| **Restore in flight, R-360** | `offboxVerifyCopyDeleteHandler` | any backup **or restore** op is running (`restoreOpBlocked()`, not `IsRunning()`). Previously it refused only during a backup, so the copy a restore was writing into could be deleted from the UI. |
|
||||
| **Cut-off database copy, R-640 (v0.267.0)** | `RestoreFromRecoveryUnit` and `ReconstituteFromOffsite`, before the first mutation; and `reimportDBDumpsFrom`, before any load, whatever the import seam is | a `<stack>-postgres.sql` / `<stack>-mariadb.sql` that does not END with its engine's completion marker (`-- PostgreSQL database dump complete` / `-- Dump completed`; `appbackup.CheckDumpComplete`, gzip-aware, last 4 KiB). A cut-off PostgreSQL copy loads with rc 0 into an empty database; a cut-off MariaDB copy fails after replacing half the tables. „…adatbázis-másolata csonka… a visszaállítás biztonsági okból nem indult el”. |
|
||||
|
||||
**The scratch completion marker** (`.felhom-restore-complete.json`, 0600, atomic) is written by
|
||||
`RestoreOffboxScratch` only after restic returns nil, and any stale one is cleared before restic starts.
|
||||
@@ -4328,6 +4329,16 @@ See `docker-compose.yml` for the full volume configuration.
|
||||
|
||||
---
|
||||
|
||||
## Unit tests never reach the real Docker (v0.267.0, R-650)
|
||||
|
||||
Every `docker` / `docker compose` / `docker-compose` process the controller builds goes through
|
||||
`internal/dockerexec` (`Command` / `CommandContext`). Under `go test` it is **refused** — the command's
|
||||
Start returns an error naming it — unless `FELHOM_TEST_REAL_DOCKER=1` is set, or the executable resolves
|
||||
under `os.TempDir()` (a test's own stub on PATH). The build host is DooPlex, whose Docker is production.
|
||||
`TestR650_NoBareDockerExec` fails on any new bare `exec.Command("docker", …)` in non-test code. Packages
|
||||
whose fixtures build a real `stacks.Manager` (`api`, `stacks`, `web`) run under `dockerexec.RunWithStub`
|
||||
from their `TestMain` (a silent stub on PATH).
|
||||
|
||||
## Test Environments
|
||||
|
||||
| Node | Hardware | Domain | Status |
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -3019,7 +3019,7 @@ func (a *exportAdapter) RemoveStackVolumes(name string) error {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "docker", "compose", "down", "--volumes")
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "compose", "down", "--volumes")
|
||||
cmd.Dir = stackDir
|
||||
cmd.Env = cmdEnv
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
)
|
||||
|
||||
// TestMain puts a silent docker stub on PATH for every test in this package: R-650, the fixtures
|
||||
// here build a real stacks.Manager, and on the build host (DooPlex) that reached production Docker.
|
||||
func TestMain(m *testing.M) { os.Exit(dockerexec.RunWithStub(m)) }
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
@@ -136,7 +138,7 @@ func DiscoverDatabases(ctx context.Context, logger *log.Logger, debug bool, know
|
||||
// whole `docker ps` output, so a trailing empty field on the LAST line would be eaten and that
|
||||
// row would arrive one column short. Image is never empty, so ending on it keeps every row the
|
||||
// same width.
|
||||
cmd := exec.CommandContext(ctx, "docker", "ps", "--format",
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "ps", "--format",
|
||||
"{{.ID}}\t{{.Names}}\t{{.Label \"com.docker.compose.project\"}}\t{{.Image}}", "--filter", "status=running")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
@@ -279,7 +281,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
|
||||
defer cancel()
|
||||
|
||||
// Verify container is still running
|
||||
checkCmd := exec.CommandContext(dumpCtx, "docker", "inspect", "--format", "{{.State.Running}}", db.ContainerID)
|
||||
checkCmd := dockerexec.CommandContext(dumpCtx, "docker", "inspect", "--format", "{{.State.Running}}", db.ContainerID)
|
||||
checkOut, err := checkCmd.Output()
|
||||
if err != nil || strings.TrimSpace(string(checkOut)) != "true" {
|
||||
result.Error = fmt.Errorf("container %s no longer running", db.ContainerName)
|
||||
@@ -294,7 +296,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
|
||||
var cmd *exec.Cmd
|
||||
switch db.DBType {
|
||||
case DBTypePostgres:
|
||||
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
"pg_dump", "-U", db.DBUser, "-d", db.DBName,
|
||||
"--clean", "--if-exists", "--no-owner", "--no-privileges")
|
||||
if debug {
|
||||
@@ -312,7 +314,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
|
||||
}
|
||||
return result
|
||||
}
|
||||
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
"mariadb-dump", "-u", "root", "-p***",
|
||||
"--single-transaction", "--routines", "--triggers", db.DBName)
|
||||
if debug {
|
||||
@@ -320,7 +322,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
|
||||
db.ContainerID[:12], db.DBName)
|
||||
}
|
||||
// Actual command with real password (not logged)
|
||||
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
"mariadb-dump", "-u", "root", "-p"+password,
|
||||
"--single-transaction", "--routines", "--triggers", db.DBName)
|
||||
default:
|
||||
@@ -671,7 +673,7 @@ func ListDumpFiles(dumpDir string, cached func(name string, size int64, mod time
|
||||
}
|
||||
|
||||
func populateDBEnv(ctx context.Context, db *DiscoveredDB) error {
|
||||
cmd := exec.CommandContext(ctx, "docker", "inspect", db.ContainerID,
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "inspect", db.ContainerID,
|
||||
"--format", "{{range .Config.Env}}{{println .}}{{end}}")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
@@ -759,14 +761,14 @@ func ImportDump(ctx context.Context, db DiscoveredDB, dumpPath string, logger *l
|
||||
// MariaDB's DDL is not transactional, so a partial apply there is unavoidable at the engine
|
||||
// and is why the rollback in offbox_reconstitute.go exists and is the actual fix. Do not
|
||||
// read this flag as making the rollback optional.
|
||||
cmd = exec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
|
||||
cmd = dockerexec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
|
||||
"psql", "-v", "ON_ERROR_STOP=1", "--single-transaction", "-U", user, "-d", dbName)
|
||||
case DBTypeMariaDB:
|
||||
password := getMariaDBPassword(impCtx, db.ContainerID)
|
||||
if password == "" {
|
||||
return fmt.Errorf("could not determine MariaDB root password for %s", db.ContainerName)
|
||||
}
|
||||
cmd = exec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
|
||||
cmd = dockerexec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
|
||||
"mariadb", "-u", "root", "-p"+password, db.DBName)
|
||||
default:
|
||||
return fmt.Errorf("unsupported DB type: %s", db.DBType)
|
||||
@@ -813,10 +815,10 @@ func waitDBReady(ctx context.Context, db DiscoveredDB, timeout time.Duration) er
|
||||
if user == "" {
|
||||
user = "postgres"
|
||||
}
|
||||
cmd = exec.CommandContext(c, "docker", "exec", db.ContainerID, "pg_isready", "-U", user)
|
||||
cmd = dockerexec.CommandContext(c, "docker", "exec", db.ContainerID, "pg_isready", "-U", user)
|
||||
case DBTypeMariaDB:
|
||||
pw := getMariaDBPassword(c, db.ContainerID)
|
||||
cmd = exec.CommandContext(c, "docker", "exec", db.ContainerID, "mariadb-admin", "ping", "-u", "root", "-p"+pw)
|
||||
cmd = dockerexec.CommandContext(c, "docker", "exec", db.ContainerID, "mariadb-admin", "ping", "-u", "root", "-p"+pw)
|
||||
default:
|
||||
cancel()
|
||||
return fmt.Errorf("unsupported DB type: %s", db.DBType)
|
||||
@@ -834,7 +836,7 @@ func waitDBReady(ctx context.Context, db DiscoveredDB, timeout time.Duration) er
|
||||
}
|
||||
|
||||
func getMariaDBPassword(ctx context.Context, containerID string) string {
|
||||
cmd := exec.CommandContext(ctx, "docker", "inspect", containerID,
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "inspect", containerID,
|
||||
"--format", "{{range .Config.Env}}{{println .}}{{end}}")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
@@ -976,3 +978,69 @@ func cleanupTmpFiles(dumpDir string, logger *log.Logger) {
|
||||
}
|
||||
|
||||
// M1: formatBytes removed — use humanizeBytes() from appdata.go (same package, no duplication).
|
||||
|
||||
// Completion markers — the last comment each engine's dump tool writes, and only when it finished.
|
||||
// R-640 (measured 2026-09-23 on 9202): the first half of a real pg_dump, loaded with
|
||||
// `psql -v ON_ERROR_STOP=1 --single-transaction`, returned rc 0 and left 42 tables with 0 users —
|
||||
// psql treats end-of-file inside a COPY as end of data and commits. A truncated MariaDB dump fails
|
||||
// its load, but only after it has already replaced half the tables. The header and CREATE TABLE
|
||||
// checks in ValidateDump cannot see either: only the END of the file can.
|
||||
const (
|
||||
pgCompleteMarker = "-- PostgreSQL database dump complete"
|
||||
mariadbCompleteMarker = "-- Dump completed"
|
||||
)
|
||||
|
||||
// ErrDumpIncomplete is returned (wrapped) by CheckDumpComplete when the marker is absent.
|
||||
var ErrDumpIncomplete = errors.New("database copy is incomplete: the engine's completion marker is missing")
|
||||
|
||||
// CheckDumpComplete reports whether a (possibly .gz) dump ends with its engine's completion marker.
|
||||
// It reads the whole stream but keeps only the last 4 KiB — pg_dump may print a `\unrestrict` line
|
||||
// after its marker, so the marker is searched in the tail rather than required on the last line.
|
||||
// An engine this function does not know is an error: "cannot tell" must never load.
|
||||
func CheckDumpComplete(path string, dbType DBType) error {
|
||||
var marker string
|
||||
switch dbType {
|
||||
case DBTypePostgres:
|
||||
marker = pgCompleteMarker
|
||||
case DBTypeMariaDB:
|
||||
marker = mariadbCompleteMarker
|
||||
default:
|
||||
return fmt.Errorf("%s: unknown database type %q, completeness cannot be checked", filepath.Base(path), dbType)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
defer f.Close()
|
||||
var r io.Reader = f
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
gr, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening gzip %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
defer gr.Close()
|
||||
r = gr
|
||||
}
|
||||
const tailSize = 4096
|
||||
tail := make([]byte, 0, 2*tailSize)
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
n, rerr := r.Read(buf)
|
||||
if n > 0 {
|
||||
tail = append(tail, buf[:n]...)
|
||||
if len(tail) > tailSize {
|
||||
tail = append(tail[:0], tail[len(tail)-tailSize:]...)
|
||||
}
|
||||
}
|
||||
if rerr == io.EOF {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("reading %s: %w", filepath.Base(path), rerr)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(tail), marker) {
|
||||
return fmt.Errorf("%s (%s): %w", filepath.Base(path), dbType, ErrDumpIncomplete)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package appbackup
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-640: CheckDumpComplete reads the END of the copy. pg_dump 17.6+ prints `\unrestrict <key>` after
|
||||
// its marker, so the marker need not be the last line.
|
||||
func TestR640_CheckDumpComplete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
big := make([]byte, 200*1024) // longer than the kept tail: the marker must be found at the END
|
||||
for i := range big {
|
||||
big[i] = 'x'
|
||||
}
|
||||
cases := []struct {
|
||||
name, body string
|
||||
t DBType
|
||||
gz, ok bool
|
||||
}{
|
||||
{"pg complete", "-- PostgreSQL database dump\nCOPY t FROM stdin;\n1\n\\.\n\n--\n-- PostgreSQL database dump complete\n--\n\n\\unrestrict abc\n", DBTypePostgres, false, true},
|
||||
{"pg cut inside COPY", "-- PostgreSQL database dump\nCOPY t FROM stdin;\n1\n", DBTypePostgres, false, false},
|
||||
{"pg marker only at the start is not an end", "-- PostgreSQL database dump complete\n" + string(big) + "\n", DBTypePostgres, false, false},
|
||||
{"pg complete, gzipped", "-- PostgreSQL database dump\n" + string(big) + "\n-- PostgreSQL database dump complete\n", DBTypePostgres, true, true},
|
||||
{"pg cut, gzipped", "-- PostgreSQL database dump\n" + string(big), DBTypePostgres, true, false},
|
||||
{"mariadb complete", "-- MariaDB dump 10.19\nINSERT INTO `t` VALUES (1);\n-- Dump completed on 2026-09-23\n", DBTypeMariaDB, false, true},
|
||||
{"mariadb cut", "-- MariaDB dump 10.19\nINSERT INTO `t` VALUES (1),(2", DBTypeMariaDB, false, false},
|
||||
{"unknown engine never passes", "anything\n-- Dump completed\n", DBType("sqlite"), false, false},
|
||||
}
|
||||
for i, c := range cases {
|
||||
p := filepath.Join(dir, "d"+string(rune('a'+i))+".sql")
|
||||
if c.gz {
|
||||
p += ".gz"
|
||||
f, _ := os.Create(p)
|
||||
w := gzip.NewWriter(f)
|
||||
w.Write([]byte(c.body))
|
||||
w.Close()
|
||||
f.Close()
|
||||
} else if err := os.WriteFile(p, []byte(c.body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := CheckDumpComplete(p, c.t)
|
||||
if (err == nil) != c.ok {
|
||||
t.Errorf("%s: ok=%v, got err=%v", c.name, c.ok, err)
|
||||
}
|
||||
if !c.ok && c.t != "sqlite" && !errors.Is(err, ErrDumpIncomplete) {
|
||||
t.Errorf("%s: want ErrDumpIncomplete, got %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -731,7 +731,7 @@ func (e *Exporter) exportHDDData(req ExportRequest, dataDir string, manifest *Ma
|
||||
// controller itself runs containerized (the v0.124.0 HIGH finding). Package var so unit tests
|
||||
// inject a recorder (no docker on test boxes).
|
||||
var dockerExec = func(ctx context.Context, stdin io.Reader, stdout io.Writer, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Stdin = stdin
|
||||
cmd.Stdout = stdout
|
||||
var errBuf bytes.Buffer
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"io"
|
||||
"os"
|
||||
@@ -941,7 +942,7 @@ func composeExecEnv(stackDir string, env map[string]string, args ...string) ([]b
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", cmdArgs...)
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", cmdArgs...)
|
||||
cmd.Dir = stackDir
|
||||
cmd.Env = os.Environ()
|
||||
for k, v := range env {
|
||||
@@ -989,14 +990,14 @@ func waitForDB(containerID, dbType string, env map[string]string) error {
|
||||
if user == "" {
|
||||
user = "postgres"
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, "docker", "exec", containerID,
|
||||
cmd = dockerexec.CommandContext(ctx, "docker", "exec", containerID,
|
||||
"pg_isready", "-U", user)
|
||||
case "mariadb":
|
||||
password := env["MYSQL_ROOT_PASSWORD"]
|
||||
if password == "" {
|
||||
password = env["MARIADB_ROOT_PASSWORD"]
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, "docker", "exec", containerID,
|
||||
cmd = dockerexec.CommandContext(ctx, "docker", "exec", containerID,
|
||||
"mysqladmin", "ping", "-u", "root", "-p"+password)
|
||||
default:
|
||||
cancel()
|
||||
@@ -1047,7 +1048,7 @@ func importDBDump(containerID, dumpPath, dbType string, env map[string]string) e
|
||||
if dbName == "" {
|
||||
dbName = user
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, "docker", "exec", "-i", containerID,
|
||||
cmd = dockerexec.CommandContext(ctx, "docker", "exec", "-i", containerID,
|
||||
"psql", "-U", user, "-d", dbName)
|
||||
case "mariadb":
|
||||
password := env["MYSQL_ROOT_PASSWORD"]
|
||||
@@ -1058,7 +1059,7 @@ func importDBDump(containerID, dumpPath, dbType string, env map[string]string) e
|
||||
if dbName == "" {
|
||||
dbName = env["MARIADB_DATABASE"]
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, "docker", "exec", "-i", containerID,
|
||||
cmd = dockerexec.CommandContext(ctx, "docker", "exec", "-i", containerID,
|
||||
"mysql", "-u", "root", "-p"+password, dbName)
|
||||
default:
|
||||
return fmt.Errorf("unknown DB type: %s", dbType)
|
||||
|
||||
@@ -3,9 +3,9 @@ package backup
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -856,7 +856,7 @@ func (m *Manager) tarVolumeOrDefault(volName, dumpDir string) ([]byte, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "run", "--rm",
|
||||
"-v", volName+":/vol:ro",
|
||||
"-v", dumpDir+":/out",
|
||||
"alpine", "tar", "cf", "/out/"+volName+".tar.tmp", "-C", "/vol", ".")
|
||||
|
||||
@@ -678,6 +678,11 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
|
||||
// dialog says so and the safety dump makes it reversible.
|
||||
res.Skewed = res.OffsiteRunID == ""
|
||||
res.LooksEmpty = m.sniffScratchDump(scratchDumpDir, stack)
|
||||
// R-640: the snapshot's database copy must be whole before anything is stopped or overwritten.
|
||||
if bad := incompleteDumps(scratchDumpDir); len(bad) > 0 {
|
||||
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: incomplete database copy %v (no completion marker)", stack, bad)
|
||||
return res, util.MsgError("err.backup.adatbazis_masolat_csonka_nem_indult", stack)
|
||||
}
|
||||
|
||||
// --- WHICH SERVICE HOLDS THE DATABASE (R-47) ------------------------------------------------
|
||||
// Read from the LIVE compose, not the scratch one: reconstitution never overwrites the stack dir,
|
||||
|
||||
@@ -82,6 +82,8 @@ COPY public."user" (id, email) FROM stdin;
|
||||
b.WriteString("id-x\tuser@example.invalid\n")
|
||||
}
|
||||
b.WriteString("\\.\n") // the COPY-block terminator
|
||||
// R-640: a real pg_dump ends with its completion marker (and, since 17.6, a \unrestrict line).
|
||||
b.WriteString("\n--\n-- PostgreSQL database dump complete\n--\n\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -303,7 +305,7 @@ func TestReconstituteFlagsCustomerEmptyDump(t *testing.T) {
|
||||
// only be honest if this reports the pair's age and warnings before anything is started.
|
||||
func TestOffsiteScratchPairReportsWhatTheConfirmNeeds(t *testing.T) {
|
||||
m, _, _ := reconFixture(t, "run1", "2026-07-19T06:00:00Z",
|
||||
"-- PostgreSQL database dump\nCREATE TABLE a();\nCOPY public.\"user\" (id) FROM stdin;\n7\n\\.\n")
|
||||
"-- PostgreSQL database dump\nCREATE TABLE a();\nCOPY public.\"user\" (id) FROM stdin;\n7\n\\.\n"+"-- PostgreSQL database dump complete\n")
|
||||
|
||||
info := m.OffsiteScratchPair("immich")
|
||||
if !info.Ready || !info.HasDump {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-640 — a cut-off database copy must never be loaded. Measured 2026-09-23 on 9202: the first half
|
||||
// of a real pg_dump loaded with rc 0 into an EMPTY database (42 tables, 0 users). Every test here
|
||||
// asserts the CONSEQUENCE — nothing was loaded, nothing was stopped — not only that an error came back.
|
||||
|
||||
// truncatedPG is pgDump cut off inside its COPY block: header and CREATE TABLEs present, so
|
||||
// ValidateDump's header + table checks pass it; only the missing end marker betrays it.
|
||||
func truncatedPG() string {
|
||||
full := pgDump(50)
|
||||
return full[:strings.Index(full, "\\.\n")]
|
||||
}
|
||||
|
||||
// COMPANION RED-PROOF: deleting the CheckDumpComplete call in reimportDBDumpsFrom makes this fail on
|
||||
// `a cut-off copy was LOADED`.
|
||||
func TestR640_ReplayRefusesACutOffCopyAndLoadsNothing(t *testing.T) {
|
||||
m := newReimportTestManager()
|
||||
ns := t.TempDir()
|
||||
p := writeDump(t, ns, "docmost", DBType("postgres"))
|
||||
if err := os.WriteFile(p, []byte(truncatedPG()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v := ValidateDump(p, DBType("postgres")); !v.Valid {
|
||||
t.Fatalf("precondition: the truncated copy must PASS the old structural check (that is the bug), got %+v", v)
|
||||
}
|
||||
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
|
||||
return []DiscoveredDB{{StackName: "docmost", ContainerName: "docmost-postgres", DBType: DBType("postgres")}}, nil
|
||||
}
|
||||
var loaded []string
|
||||
m.importDBDump = func(_ context.Context, _ DiscoveredDB, path string) error { loaded = append(loaded, path); return nil }
|
||||
|
||||
n, err := m.reimportDBDumps(context.Background(), "docmost", ns)
|
||||
if len(loaded) != 0 {
|
||||
t.Fatalf("a cut-off copy was LOADED: %v", loaded)
|
||||
}
|
||||
if err == nil || n != 0 || !strings.Contains(err.Error(), "csonka") {
|
||||
t.Fatalf("want the Hungarian cut-off refusal and 0 replayed, got n=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The control: the same path with the complete copy loads it — the check refuses nothing whole.
|
||||
func TestR640_ReplayLoadsACompleteCopy(t *testing.T) {
|
||||
m := newReimportTestManager()
|
||||
ns := t.TempDir()
|
||||
p := writeDump(t, ns, "docmost", DBType("postgres"))
|
||||
if err := os.WriteFile(p, []byte(pgDump(50)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
|
||||
return []DiscoveredDB{{StackName: "docmost", ContainerName: "docmost-postgres", DBType: DBType("postgres")}}, nil
|
||||
}
|
||||
var loaded []string
|
||||
m.importDBDump = func(_ context.Context, _ DiscoveredDB, path string) error { loaded = append(loaded, path); return nil }
|
||||
if n, err := m.reimportDBDumps(context.Background(), "docmost", ns); err != nil || n != 1 || len(loaded) != 1 {
|
||||
t.Fatalf("a complete copy must load: n=%d err=%v loaded=%v", n, err, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// The local unit restore refuses BEFORE the first mutation: no stop, no volume replay, no definition.
|
||||
// COMPANION RED-PROOF: deleting the incompleteDumps gate in RestoreFromRecoveryUnit makes this fail
|
||||
// on `the app was stopped`.
|
||||
func TestR640_UnitRestoreRefusesACutOffCopyBeforeAnyMutation(t *testing.T) {
|
||||
m, prov, imported := r47UnitFixture(t, immichLikeCompose, true)
|
||||
drive := prov.hdd
|
||||
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "app"), "app-postgres.sql"), truncatedPG())
|
||||
|
||||
_, err := m.RestoreFromRecoveryUnit("app")
|
||||
if err == nil || !strings.Contains(err.Error(), "csonka") {
|
||||
t.Fatalf("want the cut-off refusal, got %v", err)
|
||||
}
|
||||
if prov.stopped || len(prov.calls) != 0 || prov.gotEnv != nil {
|
||||
t.Fatalf("ZERO mutations required: stopped=%v calls=%v definition=%v", prov.stopped, prov.calls, prov.gotEnv != nil)
|
||||
}
|
||||
if len(*imported) != 0 {
|
||||
t.Fatalf("a replay happened: %v", *imported)
|
||||
}
|
||||
}
|
||||
|
||||
// The off-site restore refuses before the safety dump, the stop and the file copy.
|
||||
func TestR640_OffsiteRestoreRefusesACutOffCopyBeforeAnyMutation(t *testing.T) {
|
||||
m, prov, imported := reconFixture(t, "run1", "2026-07-19T06:00:00Z", truncatedPG())
|
||||
var copied bool
|
||||
m.SetOffboxFullPlaceCopier(func(_, _ string) (int, error) { copied = true; return 1, nil })
|
||||
|
||||
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
|
||||
if err == nil || !strings.Contains(err.Error(), "csonka") {
|
||||
t.Fatalf("want the cut-off refusal, got %v", err)
|
||||
}
|
||||
if len(prov.calls) != 0 || copied || len(*imported) != 0 {
|
||||
t.Fatalf("ZERO mutations required: calls=%v copied=%v imported=%v", prov.calls, copied, *imported)
|
||||
}
|
||||
}
|
||||
|
||||
// A MariaDB copy is judged by its own marker, not PostgreSQL's.
|
||||
func TestR640_MariaDBCopyNeedsItsOwnMarker(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
good := filepath.Join(dir, "romm-mariadb.sql")
|
||||
mustWrite(t, good, "-- MariaDB dump 10.19\nCREATE TABLE `users` (id int);\nINSERT INTO `users` VALUES (1);\n-- Dump completed on 2026-09-23 21:00:00\n")
|
||||
if bad := incompleteDumps(dir); len(bad) != 0 {
|
||||
t.Fatalf("a complete MariaDB copy was flagged: %v", bad)
|
||||
}
|
||||
mustWrite(t, good, "-- MariaDB dump 10.19\nCREATE TABLE `users` (id int);\nINSERT INTO `users` VALUES (1);\n-- PostgreSQL database dump complete\n")
|
||||
if bad := incompleteDumps(dir); len(bad) != 1 {
|
||||
t.Fatalf("a MariaDB copy carrying the WRONG engine's marker must be flagged, got %v", bad)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package backup
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -148,10 +148,10 @@ func (m *Manager) restoreDockerVolumesFrom(stackName, dumpDir string) (int, erro
|
||||
m.logger.Printf("[INFO] [backup] Restoring Docker volume %s for %s", volName, stackName)
|
||||
|
||||
// Remove existing volume (ignore errors — may not exist)
|
||||
exec.Command("docker", "volume", "rm", "-f", volName).Run()
|
||||
dockerexec.Command("docker", "volume", "rm", "-f", volName).Run()
|
||||
|
||||
// Create fresh volume
|
||||
if out, err := exec.Command("docker", "volume", "create", volName).CombinedOutput(); err != nil {
|
||||
if out, err := dockerexec.Command("docker", "volume", "create", volName).CombinedOutput(); err != nil {
|
||||
m.logger.Printf("[ERROR] [backup] Failed to create volume %s: %s — %v", volName, strings.TrimSpace(string(out)), err)
|
||||
failed = append(failed, volName)
|
||||
continue
|
||||
@@ -159,7 +159,7 @@ func (m *Manager) restoreDockerVolumesFrom(stackName, dumpDir string) (int, erro
|
||||
|
||||
// Populate from tar
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "run", "--rm",
|
||||
"-v", volName+":/vol",
|
||||
"-v", dumpDir+":/in:ro",
|
||||
"alpine", "tar", "xf", "/in/"+entry.Name(), "-C", "/vol")
|
||||
|
||||
@@ -5,7 +5,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// reimportDBDumps replays the captured per-app .sql dumps back into the app's now-running database
|
||||
@@ -74,6 +78,12 @@ func (m *Manager) reimportDBDumpsFrom(ctx context.Context, stackName, dumpDir st
|
||||
if _, statErr := os.Stat(dumpPath); statErr != nil {
|
||||
continue // no dump for this particular DB engine
|
||||
}
|
||||
// R-640: a cut-off copy loads as SUCCESS into an empty PostgreSQL database. Checked here, on
|
||||
// the one path every replay takes, whatever `imp` is — the seam must not be able to skip it.
|
||||
if err := appbackup.CheckDumpComplete(dumpPath, db.DBType); err != nil {
|
||||
m.logger.Printf("[ERROR] [backup] Restore %s: NOT replaying %s — %v", stackName, filepath.Base(dumpPath), err)
|
||||
return imported, util.MsgError("err.backup.adatbazis_masolat_csonka_nem_toltve", stackName)
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] Restore %s: replaying DB dump into %s (%s)", stackName, db.ContainerName, db.DBType)
|
||||
if err := imp(ctx, db, dumpPath); err != nil {
|
||||
return imported, fmt.Errorf("importing %s dump for %s: %w", db.DBType, stackName, err)
|
||||
@@ -111,3 +121,34 @@ func (m *Manager) reimportDBDumpsAtCtx(stackName, dumpDir string) (int, error) {
|
||||
defer cancel()
|
||||
return m.reimportDBDumpsFrom(ctx, stackName, dumpDir)
|
||||
}
|
||||
|
||||
// incompleteDumps names the replayable dumps in dumpDir that do not end with their engine's
|
||||
// completion marker (R-640). Only the files a replay would load are checked: `<stack>-<engine>.sql`,
|
||||
// never the pre-restore safety copies. A directory that cannot be read yields nothing here — the
|
||||
// replay's own ReadDir reports that.
|
||||
func incompleteDumps(dumpDir string) []string {
|
||||
entries, err := os.ReadDir(dumpDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var bad []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || filepath.Ext(name) != ".sql" || strings.HasPrefix(name, preRestoreDumpPrefix) {
|
||||
continue
|
||||
}
|
||||
var t DBType
|
||||
switch {
|
||||
case strings.HasSuffix(name, "-"+string(appbackup.DBTypePostgres)+".sql"):
|
||||
t = appbackup.DBTypePostgres
|
||||
case strings.HasSuffix(name, "-"+string(appbackup.DBTypeMariaDB)+".sql"):
|
||||
t = appbackup.DBTypeMariaDB
|
||||
default:
|
||||
continue // not a file the replay loads
|
||||
}
|
||||
if err := appbackup.CheckDumpComplete(filepath.Join(dumpDir, name), t); err != nil {
|
||||
bad = append(bad, name)
|
||||
}
|
||||
}
|
||||
return bad
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
)
|
||||
|
||||
func newReimportTestManager() *Manager {
|
||||
@@ -22,7 +24,12 @@ func writeDump(t *testing.T, nsRoot, stack string, dbType DBType) string {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := filepath.Join(dir, fmt.Sprintf("%s-%s.sql", stack, dbType))
|
||||
if err := os.WriteFile(p, []byte("-- dump\nDROP TABLE IF EXISTS t;\n"), 0o644); err != nil {
|
||||
// R-640: a complete dump ends with its engine's marker, as the real tools write it.
|
||||
end := "-- PostgreSQL database dump complete\n"
|
||||
if dbType == appbackup.DBTypeMariaDB {
|
||||
end = "-- Dump completed on 2026-09-23 21:00:00\n"
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("-- dump\nDROP TABLE IF EXISTS t;\n"+end), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
|
||||
@@ -393,6 +393,13 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
|
||||
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: a .sql dump exists but no database service is identifiable in the unit's compose", stackName)
|
||||
return res, util.MsgError("err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a", stackName)
|
||||
}
|
||||
// R-640: a cut-off database copy is refused HERE, before the first mutation, so the live app is
|
||||
// untouched — loading it would report success over an empty (PostgreSQL) or half-replaced
|
||||
// (MariaDB) database.
|
||||
if bad := incompleteDumps(dbDumpDir); len(bad) > 0 {
|
||||
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: incomplete database copy %v (no completion marker)", stackName, bad)
|
||||
return res, util.MsgError("err.backup.adatbazis_masolat_csonka_nem_indult", stackName)
|
||||
}
|
||||
|
||||
// Stop, restore named-volume data, recreate the definition, replay the DB with ONLY the database
|
||||
// service running, and only then start the whole stack.
|
||||
|
||||
@@ -3,8 +3,8 @@ package backup
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -89,7 +89,7 @@ func (m *Manager) sharesPassdbCapturer() func() ([]byte, error) {
|
||||
// manifest-only (re-setting the SMB password is a cheap UX step; the definitions are the load-bearing
|
||||
// part). Uses the same `docker exec` shape as stacks.extractInitialCreds.
|
||||
func defaultSharesPassdbCapture() ([]byte, error) {
|
||||
cmd := exec.Command("docker", "exec", infra.SambaContainerName,
|
||||
cmd := dockerexec.Command("docker", "exec", infra.SambaContainerName,
|
||||
"tar", "cf", "-", "-C", infra.SambaPassdbMount, sharesPassdbMember)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -45,7 +45,7 @@ func (m *Manager) sharesPassdbRestorer() func([]byte) error {
|
||||
// writes ONLY into infra.SambaPassdbMount inside the samba container — never onto the host — so a
|
||||
// malformed archive cannot reach anything outside the volume it came from.
|
||||
func defaultSharesPassdbRestore(tar []byte) error {
|
||||
cmd := exec.Command("docker", "exec", "-i", infra.SambaContainerName,
|
||||
cmd := dockerexec.Command("docker", "exec", "-i", infra.SambaContainerName,
|
||||
"tar", "xf", "-", "-C", infra.SambaPassdbMount)
|
||||
cmd.Stdin = bytes.NewReader(tar)
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Package dockerexec is the ONE way the controller builds a `docker` / `docker compose` /
|
||||
// `docker-compose` process. It exists for R-650: the controller's unit tests run on DooPlex, the
|
||||
// build host, which is production Docker (Gitea, the registry, k3s). A test that fell through to a
|
||||
// real `docker run … tar` created a volume there, and one ran a real `docker compose down`.
|
||||
//
|
||||
// Under `go test` a docker command is REFUSED — its Start/Run/Output returns an error naming the
|
||||
// command — unless one of two things holds:
|
||||
//
|
||||
// - FELHOM_TEST_REAL_DOCKER=1 is set (a deliberate, per-run opt-in); or
|
||||
// - the executable resolves under os.TempDir() — a stub a test wrote into t.TempDir() and put on
|
||||
// PATH, which is a seam, not Docker.
|
||||
//
|
||||
// Outside `go test` (the real controller) nothing changes: Command is exec.Command.
|
||||
// Pinned by TestR650_* in this package and by the repo-wide TestR650_NoBareDockerExec sweep.
|
||||
package dockerexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// OptInEnv is the environment variable that lets a test reach the real docker on purpose.
|
||||
const OptInEnv = "FELHOM_TEST_REAL_DOCKER"
|
||||
|
||||
// underTest is a variable so this package's own tests can model the production binary.
|
||||
var underTest = testing.Testing
|
||||
|
||||
// IsDocker reports whether name is a docker binary (by base name).
|
||||
func IsDocker(name string) bool {
|
||||
b := filepath.Base(name)
|
||||
return b == "docker" || b == "docker-compose"
|
||||
}
|
||||
|
||||
// refusal returns the error a refused command carries, or nil when the command may run.
|
||||
func refusal(name string, args []string) error {
|
||||
if !IsDocker(name) || !underTest() || os.Getenv(OptInEnv) == "1" {
|
||||
return nil
|
||||
}
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
if abs, aerr := filepath.Abs(p); aerr == nil {
|
||||
tmp := filepath.Clean(os.TempDir()) + string(os.PathSeparator)
|
||||
if strings.HasPrefix(abs, tmp) {
|
||||
return nil // a test's own stub on PATH
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("R-650: refused to run the real %q under go test (this host may be production Docker); "+
|
||||
"use a seam or a stub on PATH, or set %s=1 deliberately",
|
||||
strings.TrimSpace(name+" "+strings.Join(args, " ")), OptInEnv)
|
||||
}
|
||||
|
||||
// Command is exec.Command for a docker binary, refused under go test (see package doc).
|
||||
// A non-docker name passes through unchanged, so a generic runner may call it for any command.
|
||||
func Command(name string, args ...string) *exec.Cmd {
|
||||
cmd := exec.Command(name, args...)
|
||||
if err := refusal(name, args); err != nil {
|
||||
cmd.Err = err
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// CommandContext is exec.CommandContext with the same guard.
|
||||
func CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if err := refusal(name, args); err != nil {
|
||||
cmd.Err = err
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Runner is what *testing.M offers; named so this file does not need a test-only type.
|
||||
type Runner interface{ Run() int }
|
||||
|
||||
// RunWithStub is for a package's TestMain: it puts a `docker` and a `docker-compose` that print
|
||||
// nothing and exit 0 at the front of PATH for the whole test binary, then runs the tests. It is the
|
||||
// package-wide seam for fixtures that build a real stacks.Manager (whose NewManager/ScanStacks run
|
||||
// `docker compose version` and `docker ps`). A test that needs a specific answer still writes its
|
||||
// own stub; a test that needs the real docker sets OptInEnv. (R-650)
|
||||
func RunWithStub(m Runner) int {
|
||||
dir, err := os.MkdirTemp("", "r650-docker-stub-")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "R-650 stub:", err)
|
||||
return 1
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
for _, n := range []string{"docker", "docker-compose"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "R-650 stub:", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
os.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return m.Run()
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package dockerexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestR650_RealDockerIsRefusedUnderGoTest is the decoy: a harmless-looking `docker ps` with the
|
||||
// real PATH must NOT run. The consequence asserted is that Run returns the refusal, naming the
|
||||
// command — and that nothing was started (ProcessState stays nil).
|
||||
func TestR650_RealDockerIsRefusedUnderGoTest(t *testing.T) {
|
||||
t.Setenv(OptInEnv, "")
|
||||
for _, name := range []string{"docker", "docker-compose", "/usr/bin/docker"} {
|
||||
cmd := Command(name, "ps", "-a")
|
||||
err := cmd.Run()
|
||||
if err == nil || !strings.Contains(err.Error(), "R-650") || !strings.Contains(err.Error(), "ps -a") {
|
||||
t.Fatalf("%s: want an R-650 refusal naming the command, got %v", name, err)
|
||||
}
|
||||
if cmd.ProcessState != nil {
|
||||
t.Fatalf("%s: a process was started", name)
|
||||
}
|
||||
c2 := CommandContext(context.Background(), name, "volume", "create", "r650-decoy")
|
||||
if _, err := c2.CombinedOutput(); err == nil || !strings.Contains(err.Error(), "volume create r650-decoy") {
|
||||
t.Fatalf("%s: CommandContext not refused: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestR650_StubOnPathIsAllowed — a test's own fake in t.TempDir() is a seam, not Docker.
|
||||
func TestR650_StubOnPathIsAllowed(t *testing.T) {
|
||||
bin := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\necho stub:$*\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin)
|
||||
out, err := Command("docker", "ps").CombinedOutput()
|
||||
if err != nil || strings.TrimSpace(string(out)) != "stub:ps" {
|
||||
t.Fatalf("stub should run: out=%q err=%v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestR650_OptInAndProductionAndNonDockerPassThrough — the guard refuses nothing else.
|
||||
func TestR650_OptInAndProductionAndNonDockerPassThrough(t *testing.T) {
|
||||
t.Setenv(OptInEnv, "1")
|
||||
if err := refusal("docker", []string{"ps"}); err != nil {
|
||||
t.Fatalf("opt-in must allow: %v", err)
|
||||
}
|
||||
t.Setenv(OptInEnv, "")
|
||||
if err := refusal("du", []string{"-sb", "/"}); err != nil {
|
||||
t.Fatalf("a non-docker command must pass: %v", err)
|
||||
}
|
||||
old := underTest
|
||||
underTest = func() bool { return false }
|
||||
defer func() { underTest = old }()
|
||||
if err := refusal("docker", []string{"ps"}); err != nil {
|
||||
t.Fatalf("the production binary must never refuse: %v", err)
|
||||
}
|
||||
if Command("docker", "ps").Err != nil {
|
||||
t.Fatal("production Command carried an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestR650_NoBareDockerExec pins the invariant the package doc states: every production path that
|
||||
// builds a docker process goes through this package. A new `exec.Command("docker", …)` in
|
||||
// non-test code fails here, naming the file and line.
|
||||
func TestR650_NoBareDockerExec(t *testing.T) {
|
||||
root := filepath.Join("..", "..")
|
||||
bare := regexp.MustCompile(`\bexec\.Command(Context)?\(([^,()]+, )?"docker`)
|
||||
var hits []string
|
||||
n := 0
|
||||
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n++
|
||||
for i, line := range strings.Split(string(b), "\n") {
|
||||
if bare.MatchString(line) {
|
||||
hits = append(hits, p+":"+strconv.Itoa(i+1)+": "+strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n < 100 {
|
||||
t.Fatalf("scope: only %d Go files walked — the sweep is not looking at the tree", n)
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
t.Fatalf("bare docker exec outside dockerexec (R-650):\n%s", strings.Join(hits, "\n"))
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,7 @@
|
||||
"backups.a_kijeloles_nem_sikerult": "The drive could not be assigned:",
|
||||
"backups.a_meghajto_kijelolve": "The drive is assigned.",
|
||||
"backups.a_meghajto_kijelolve_a_beallitas": "The drive is assigned. The setting takes effect after the host agent next restarts.",
|
||||
"backups.a_mentes_alatt_az_alkalmazasok": "Your apps stop during the backup — usually for a few minutes, longer with more data.",
|
||||
"backups.a_mentes_alatt_az_alkalmazasok": "Every app stops for as long as the full system backup takes — that can be several minutes (about 8 minutes, measured on a box with 12 apps), longer with more data.",
|
||||
"backups.a_mentes_sikertelen": "The backup failed.",
|
||||
"backups.a_mentesek_egymas_utan_futnak": "The backups run one after the other: database backup, local copy, remote backup, then the full system backup.",
|
||||
"backups.a_rendszermentes_elkeszult": "The system backup is done.",
|
||||
@@ -201,7 +201,7 @@
|
||||
"backups.beagyazott_db_k_a_kotetmentesben": "embedded databases, inside the volume backup",
|
||||
"backups.biztonsagi_mentes": "Backup",
|
||||
"backups.db_mentesek": "Database backups",
|
||||
"backups.elinditod_a_teljes_rendszermentest_most": "Start the full system backup now? Your apps stop during the backup — usually for a few minutes, longer with more data.",
|
||||
"backups.elinditod_a_teljes_rendszermentest_most": "Start the full system backup now? Every app stops for as long as the full system backup takes — that can be several minutes (about 8 minutes, measured on a box with 12 apps), longer with more data.",
|
||||
"backups.esedekes": "Due",
|
||||
"backups.fajl": "{{$n := len .Backup.DumpFiles}}{{$n}} {{if eq $n 1}}file{{else}}files{{end}}",
|
||||
"backups.helyi_masolat": "Local copy: <strong>{{.BackupLegTier2}}</strong>",
|
||||
@@ -1199,6 +1199,8 @@
|
||||
"err.backup.a_z_ujrainditasa_sikertelen_a_fajlok": "%s could not be restarted after its files were put back: %s",
|
||||
"err.backup.adatathelyezes_folyamatban_a_mentes_most_nem": "data is being moved — the backup cannot start right now",
|
||||
"err.backup.adatbazis_felderites_sikertelen": "finding the databases failed: %s",
|
||||
"err.backup.adatbazis_masolat_csonka_nem_indult": "The database copy of %s is cut off: it does not end the way a complete copy does. The restore did not start, for safety — the app and its data are untouched.",
|
||||
"err.backup.adatbazis_masolat_csonka_nem_toltve": "The database copy of %s is cut off: it does not end the way a complete copy does. That copy was not loaded — the database was not restored from it.",
|
||||
"err.backup.adatbazis_mentes_sikertelen": "the database backup failed (%s): %s",
|
||||
"err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a": "The database service cannot be identified in %s — the restore did not start, for safety.",
|
||||
"err.backup.az_alkalmazas_meghajtoja_le_van_szerelve": "the app’s drive is decommissioned",
|
||||
@@ -2345,7 +2347,6 @@
|
||||
"tier2_config.cel": "Target",
|
||||
"tier2_config.cel_meghajto": "Target drive",
|
||||
"tier2_config.csak_db_konfiguracio": "— database/configuration only",
|
||||
"tier2_config.ennek_az_alkalmazasnak_az_adatai": "This app’s data is on the internal system disk, which is\n <strong>already part of the full system backup (PBS)</strong>. The 2nd (off-drive) copy\n is extra, and is made mainly for apps stored on the external data drive — nothing to do for this\n app.",
|
||||
"tier2_config.jelenleg": "(now: {{.EffectiveLabel}})",
|
||||
"tier2_config.jelenleg_csak_a_belso_ssd": "Only the internal SSD is available as a 2nd target right now, so only the database and the configuration\n are copied. The internal system disk is small, so an off-drive backup of large files needs a\n <strong>2nd data drive</strong> (so the system disk does not fill up).",
|
||||
"tier2_config.jelenlegi_allapot": "Current state",
|
||||
@@ -2356,6 +2357,10 @@
|
||||
"tier2_config.mentes": "Save",
|
||||
"tier2_config.nincs_elerheto_off_drive_cel": "No off-drive target available",
|
||||
"tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "No other data drive — the automatic target is the internal SSD (database/configuration only). Add a 2nd\n data drive to back up all data off-drive too.",
|
||||
"tier2_config.rendszerlemez.hianyzik": "This app’s data is on the internal system disk. The full system backup’s drive\n <strong>is not reachable right now</strong> — until you reconnect it, no fresh system backup is made of this app either.",
|
||||
"tier2_config.rendszerlemez.ismeretlen": "This app’s data is on the internal system disk and is part of the full system backup.\n Where that backup is being made right now could not be checked — the Backups page shows it.",
|
||||
"tier2_config.rendszerlemez.ugyanaz": "This app’s data is on the internal system disk. The full system backup\n <strong>is currently made to that same disk</strong> — so it protects against damaged files, not against a failed disk.\n Connect a second drive and choose it as the system backup location on the Backups page, and this app is protected too.",
|
||||
"tier2_config.rendszerlemez.vedett": "This app’s data is on the internal system disk, which is\n <strong>part of the full system backup</strong>, and that backup is made to a separate drive. The 2nd (off-drive) copy\n is extra, and is made mainly for apps stored on the external data drive — nothing to do for this\n app.",
|
||||
"tier2_config.vissza_a_mentesekhez": "← Back to backups",
|
||||
"update.error.backup_failed": "The update did not start, because the backup before the update did not succeed: %v. The app keeps running unchanged.",
|
||||
"update.error.backup_no_unit": "The update did not start: the backup before the update ran, but no fresh copy that can be restored was made. The app keeps running unchanged.",
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"backups.a_kijeloles_nem_sikerult": "A kijelölés nem sikerült:",
|
||||
"backups.a_meghajto_kijelolve": "A meghajtó kijelölve.",
|
||||
"backups.a_meghajto_kijelolve_a_beallitas": "A meghajtó kijelölve. A beállítás a host-ügynök következő újraindulása után lép életbe.",
|
||||
"backups.a_mentes_alatt_az_alkalmazasok": "A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.",
|
||||
"backups.a_mentes_alatt_az_alkalmazasok": "A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.",
|
||||
"backups.a_mentes_sikertelen": "A mentés sikertelen.",
|
||||
"backups.a_mentesek_egymas_utan_futnak": "A mentések egymás után futnak: adatbázis-mentés, helyi másolat, távoli mentés, majd a teljes rendszermentés.",
|
||||
"backups.a_rendszermentes_elkeszult": "A rendszermentés elkészült.",
|
||||
@@ -197,7 +197,7 @@
|
||||
"backups.beagyazott_db_k_a_kotetmentesben": "beágyazott DB-k a kötetmentésben",
|
||||
"backups.biztonsagi_mentes": "Biztonsági mentés",
|
||||
"backups.db_mentesek": "DB mentések",
|
||||
"backups.elinditod_a_teljes_rendszermentest_most": "Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.",
|
||||
"backups.elinditod_a_teljes_rendszermentest_most": "Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.",
|
||||
"backups.esedekes": "Esedékes",
|
||||
"backups.fajl": "{{len .Backup.DumpFiles}} fájl",
|
||||
"backups.helyi_masolat": "Helyi másolat: <strong>{{.BackupLegTier2}}</strong>",
|
||||
@@ -1194,6 +1194,8 @@
|
||||
"err.backup.a_z_ujrainditasa_sikertelen_a_fajlok": "a(z) %s újraindítása sikertelen a fájlok visszaállítása után: %s",
|
||||
"err.backup.adatathelyezes_folyamatban_a_mentes_most_nem": "adatáthelyezés folyamatban — a mentés most nem indítható",
|
||||
"err.backup.adatbazis_felderites_sikertelen": "adatbázis-felderítés sikertelen: %s",
|
||||
"err.backup.adatbazis_masolat_csonka_nem_indult": "A(z) %s adatbázis-másolata csonka: nem ér véget, ahogy egy teljes másolat. A visszaállítás biztonsági okból nem indult el — az alkalmazás és az adatai érintetlenek.",
|
||||
"err.backup.adatbazis_masolat_csonka_nem_toltve": "A(z) %s adatbázis-másolata csonka: nem ér véget, ahogy egy teljes másolat. Ezt a másolatot nem töltöttük vissza — az adatbázis nem ebből a másolatból állt vissza.",
|
||||
"err.backup.adatbazis_mentes_sikertelen": "adatbázis-mentés sikertelen (%s): %s",
|
||||
"err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a": "Az adatbázis-szolgáltatás nem azonosítható a(z) %s alkalmazásban — a visszaállítás biztonsági okból nem indult el.",
|
||||
"err.backup.az_alkalmazas_meghajtoja_le_van_szerelve": "az alkalmazás meghajtója le van szerelve",
|
||||
@@ -2333,7 +2335,6 @@
|
||||
"tier2_config.cel": "Cél",
|
||||
"tier2_config.cel_meghajto": "Cél meghajtó",
|
||||
"tier2_config.csak_db_konfiguracio": "— csak DB/konfiguráció",
|
||||
"tier2_config.ennek_az_alkalmazasnak_az_adatai": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek\n <strong>már szerepelnek a teljes rendszermentésben (PBS)</strong>. A 2. (off-drive) másolat\n kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az\n alkalmazáshoz nincs külön teendő.",
|
||||
"tier2_config.jelenleg": "(jelenleg: {{.EffectiveLabel}})",
|
||||
"tier2_config.jelenleg_csak_a_belso_ssd": "Jelenleg csak a belső SSD érhető el 2. célként, ezért csak az adatbázis és a konfiguráció\n másolódik. A belső rendszerlemez kicsi, ezért a nagy fájlok off-drive mentéséhez egy\n <strong>2. adatmeghajtó</strong> szükséges (hogy a rendszerlemez ne teljen meg).",
|
||||
"tier2_config.jelenlegi_allapot": "Jelenlegi állapot",
|
||||
@@ -2344,6 +2345,10 @@
|
||||
"tier2_config.mentes": "Mentés",
|
||||
"tier2_config.nincs_elerheto_off_drive_cel": "Nincs elérhető off-drive cél",
|
||||
"tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "Nincs másik adatmeghajtó — automatikus cél a belső SSD (csak DB/konfiguráció). Egy 2.\n adatmeghajtó hozzáadásával a teljes adat is off-drive menthető.",
|
||||
"tier2_config.rendszerlemez.hianyzik": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak. A teljes rendszermentés meghajtója\n <strong>most nem érhető el</strong> — amíg vissza nem csatlakoztatod, erről az alkalmazásról sem készül friss rendszermentés.",
|
||||
"tier2_config.rendszerlemez.ismeretlen": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, és a teljes rendszermentés része.\n Hogy a rendszermentés most hová készül, azt nem tudtuk lekérdezni — a Mentések oldalon láthatod.",
|
||||
"tier2_config.rendszerlemez.ugyanaz": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak. A teljes rendszermentés\n <strong>jelenleg ugyanerre a lemezre készül</strong> — így hibás fájlok ellen véd, lemezhiba ellen nem.\n Ha csatlakoztatsz egy második meghajtót, és a Mentések oldalon kijelölöd a rendszermentés helyéül, ez az alkalmazás is védett lesz.",
|
||||
"tier2_config.rendszerlemez.vedett": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek\n <strong>szerepelnek a teljes rendszermentésben</strong>, és az egy külön meghajtóra készül. A 2. (off-drive) másolat\n kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az\n alkalmazáshoz nincs külön teendő.",
|
||||
"tier2_config.vissza_a_mentesekhez": "← Vissza a mentésekhez",
|
||||
"update.error.backup_failed": "A frissítés nem indult el, mert a frissítés előtti biztonsági mentés nem sikerült: %v. Az alkalmazás változatlanul fut tovább.",
|
||||
"update.error.backup_no_unit": "A frissítés nem indult el: a frissítés előtti mentés lefutott, de nem jött létre friss, visszaállítható másolat. Az alkalmazás változatlanul fut tovább.",
|
||||
|
||||
@@ -3,8 +3,8 @@ package integrations
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -67,7 +67,7 @@ func (h *OnlyOfficeNextcloudHandler) Apply(ac *ApplyContext) error {
|
||||
|
||||
for _, cmd := range commands {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
c := exec.CommandContext(ctx, cmd.args[0], cmd.args[1:]...)
|
||||
c := dockerexec.CommandContext(ctx, cmd.args[0], cmd.args[1:]...)
|
||||
out, err := c.CombinedOutput()
|
||||
cancel()
|
||||
if err != nil {
|
||||
@@ -89,7 +89,7 @@ func (h *OnlyOfficeNextcloudHandler) Revoke(ac *ApplyContext) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:disable", "onlyoffice")
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:disable", "onlyoffice")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
outStr := string(out)
|
||||
|
||||
@@ -3,8 +3,8 @@ package metrics
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -100,7 +100,7 @@ func (c *MetricsCollector) sampleContainers(parentCtx context.Context) []Contain
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", "stats", "--no-stream",
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "stats", "--no-stream",
|
||||
"--format", "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
|
||||
@@ -3,8 +3,8 @@ package metrics
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"log"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -98,7 +98,7 @@ func scanOneContainer(name string, since time.Duration, logger *log.Logger) Cont
|
||||
defer cancel()
|
||||
|
||||
sinceStr := formatSinceDuration(since)
|
||||
cmd := exec.CommandContext(ctx, "docker", "logs", "--since="+sinceStr, "--tail=1000", name)
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "logs", "--since="+sinceStr, "--tail=1000", name)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
@@ -238,7 +238,7 @@ func FetchContainerLogTail(name string, tailLines int) (string, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "docker", "logs", fmt.Sprintf("--tail=%d", tailLines), name)
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "logs", fmt.Sprintf("--tail=%d", tailLines), name)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docker logs %s: %w", name, err)
|
||||
|
||||
@@ -2,9 +2,9 @@ package monitor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -280,7 +280,7 @@ func (r *HealthReport) FormatMessage() string {
|
||||
}
|
||||
|
||||
func checkDocker() error {
|
||||
cmd := exec.Command("docker", "info", "--format", "{{.ServerVersion}}")
|
||||
cmd := dockerexec.Command("docker", "info", "--format", "{{.ServerVersion}}")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker not reachable: %v", err)
|
||||
@@ -342,7 +342,7 @@ func EffectiveProtected(cfg *config.Config, smb settings.SMBSettings) []string {
|
||||
func checkProtectedContainers(protected []string) []string {
|
||||
var missing []string
|
||||
for _, name := range protected {
|
||||
cmd := exec.Command("docker", "inspect", "--format", "{{.State.Running}}", name)
|
||||
cmd := dockerexec.Command("docker", "inspect", "--format", "{{.State.Running}}", name)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
missing = append(missing, name)
|
||||
|
||||
@@ -3,10 +3,10 @@ package selftest
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -91,7 +91,7 @@ func checkDockerSocket() CheckResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.ServerVersion}}").Output()
|
||||
out, err := dockerexec.CommandContext(ctx, "docker", "info", "--format", "{{.ServerVersion}}").Output()
|
||||
if err != nil {
|
||||
return CheckResult{Name: "Docker socket", Status: "fail", Message: fmt.Sprintf("docker info failed: %v", err)}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -858,7 +858,7 @@ func (u *Updater) VerifyStartup() *UpdateState {
|
||||
// runCommand executes a command and returns combined stdout+stderr and error.
|
||||
// Package var so tests can fake the docker CLI (no real exec in unit tests).
|
||||
var runCommand = func(name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd := dockerexec.Command(name, args...)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
@@ -870,7 +870,7 @@ var runCommand = func(name string, args ...string) (string, error) {
|
||||
// `docker login --password-stdin` so the token is never in argv/ps). Returns combined output.
|
||||
// Package var so tests can fake the docker CLI.
|
||||
var runCommandStdin = func(stdin, name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd := dockerexec.Command(name, args...)
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
@@ -2,8 +2,8 @@ package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ func (m *Manager) guestNetExec(args ...string) (string, error) {
|
||||
if m.guestNetExecFn != nil {
|
||||
return m.guestNetExecFn(args...)
|
||||
}
|
||||
out, err := exec.Command("docker", append([]string{"exec", sambaContainer}, args...)...).Output()
|
||||
out, err := dockerexec.Command("docker", append([]string{"exec", sambaContainer}, args...)...).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docker exec %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -221,7 +221,7 @@ func (m *Manager) wireController(traefikDir string) error {
|
||||
}
|
||||
|
||||
if !containerOnNetwork(controllerContainer, traefikNetwork) {
|
||||
out, err := exec.Command("docker", "network", "connect", traefikNetwork, controllerContainer).CombinedOutput()
|
||||
out, err := dockerexec.Command("docker", "network", "connect", traefikNetwork, controllerContainer).CombinedOutput()
|
||||
if err != nil && !strings.Contains(string(out), "already exists") {
|
||||
return fmt.Errorf("network connect %s: %s: %w", controllerContainer, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
@@ -234,7 +234,7 @@ func (m *Manager) wireController(traefikDir string) error {
|
||||
// We list the network names and match exactly — NOT `{{index .Networks "name"}}`, whose output for an
|
||||
// absent key is "<nil>" (a non-empty string), which would falsely read as "already attached".
|
||||
func containerOnNetwork(name, network string) bool {
|
||||
out, err := exec.Command("docker", "inspect", "--format",
|
||||
out, err := dockerexec.Command("docker", "inspect", "--format",
|
||||
"{{range $k, $_ := .NetworkSettings.Networks}}{{$k}}\n{{end}}", name).Output()
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -250,14 +250,14 @@ func containerOnNetwork(name, network string) bool {
|
||||
// ensureTraefikNetwork creates the external traefik-public docker network if absent (idempotent;
|
||||
// tolerates a create/inspect race). Uses the docker CLI directly — it's a network op, not compose.
|
||||
func (m *Manager) ensureTraefikNetwork() error {
|
||||
if exec.Command("docker", "network", "inspect", traefikNetwork).Run() == nil {
|
||||
if dockerexec.Command("docker", "network", "inspect", traefikNetwork).Run() == nil {
|
||||
return nil
|
||||
}
|
||||
m.logger.Printf("[INFO] [infra] creating docker network %s", traefikNetwork)
|
||||
out, err := exec.Command("docker", "network", "create", traefikNetwork).CombinedOutput()
|
||||
out, err := dockerexec.Command("docker", "network", "create", traefikNetwork).CombinedOutput()
|
||||
if err != nil {
|
||||
// Tolerate a race where another actor created it between our inspect and create.
|
||||
if exec.Command("docker", "network", "inspect", traefikNetwork).Run() == nil {
|
||||
if dockerexec.Command("docker", "network", "inspect", traefikNetwork).Run() == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("network create %s: %s: %w", traefikNetwork, strings.TrimSpace(string(out)), err)
|
||||
@@ -295,7 +295,7 @@ func writeInfraFiles(dir string, files map[string]infra.FileSpec) error {
|
||||
// containerRunning reports whether a container with the given name is currently running. It asks the
|
||||
// daemon directly (works before the stack dir exists), mirroring monitor.checkProtectedContainers.
|
||||
func containerRunning(name string) bool {
|
||||
out, err := exec.Command("docker", "inspect", "--format", "{{.State.Running}}", name).Output()
|
||||
out, err := dockerexec.Command("docker", "inspect", "--format", "{{.State.Running}}", name).Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package stacks
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
@@ -46,7 +46,7 @@ func (m *Manager) ReadInitialCredentials(stackName string) (*ExtractedCreds, err
|
||||
return &ExtractedCreds{Available: false}, nil
|
||||
}
|
||||
|
||||
out, err := exec.Command("docker", "exec", container, "cat", spec.File).Output()
|
||||
out, err := dockerexec.Command("docker", "exec", container, "cat", spec.File).Output()
|
||||
if err != nil {
|
||||
// File missing / container not exec-able yet — expected during early boot or after the
|
||||
// customer deletes the file. Not an error worth surfacing; hide the card.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -32,7 +33,7 @@ const installedRecordTimeout = 30 * time.Second
|
||||
type execRunner func(ctx context.Context, dir string, env []string, name string, args ...string) (string, error)
|
||||
|
||||
func defaultExecRunner(ctx context.Context, dir string, env []string, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd := dockerexec.CommandContext(ctx, name, args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -500,7 +501,7 @@ func toTitleCase(s string) string {
|
||||
}
|
||||
|
||||
func detectComposeCommand() string {
|
||||
if err := exec.Command("docker", "compose", "version").Run(); err == nil {
|
||||
if err := dockerexec.Command("docker", "compose", "version").Run(); err == nil {
|
||||
return "docker compose"
|
||||
}
|
||||
if _, err := exec.LookPath("docker-compose"); err == nil {
|
||||
@@ -1377,9 +1378,9 @@ func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string)
|
||||
|
||||
if m.composeCmd == "docker compose" {
|
||||
fullArgs := append([]string{"compose"}, args...)
|
||||
cmd = exec.Command("docker", fullArgs...)
|
||||
cmd = dockerexec.Command("docker", fullArgs...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
cmd = dockerexec.Command("docker-compose", args...)
|
||||
}
|
||||
|
||||
cmd.Dir = dir
|
||||
@@ -1456,7 +1457,7 @@ func (m *Manager) execCommand(name string, args ...string) (string, error) {
|
||||
if m.execFn != nil {
|
||||
return m.execFn(name, args...)
|
||||
}
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd := dockerexec.Command(name, args...)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -1545,7 +1546,7 @@ func (m *Manager) checkLocalImages(name, stackDir string) {
|
||||
|
||||
m.logger.Printf("[INFO] [stacks] Deploying stack %s — checking %d images...", name, len(images))
|
||||
for _, img := range images {
|
||||
cmd := exec.Command("docker", "image", "inspect", img)
|
||||
cmd := dockerexec.Command("docker", "image", "inspect", img)
|
||||
if err := cmd.Run(); err != nil {
|
||||
m.logger.Printf("[DEBUG] %s — not found locally, will pull", img)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
)
|
||||
|
||||
// TestMain puts a silent docker stub on PATH for every test in this package: R-650, the fixtures
|
||||
// here build a real stacks.Manager, and on the build host (DooPlex) that reached production Docker.
|
||||
func TestMain(m *testing.M) { os.Exit(dockerexec.RunWithStub(m)) }
|
||||
@@ -2,10 +2,10 @@ package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -98,7 +98,7 @@ func (m *Manager) SambaImagePresent() bool {
|
||||
if m.sambaImgFn != nil {
|
||||
return m.sambaImgFn()
|
||||
}
|
||||
return exec.Command("docker", "image", "inspect", infra.SambaImage).Run() == nil
|
||||
return dockerexec.Command("docker", "image", "inspect", infra.SambaImage).Run() == nil
|
||||
}
|
||||
|
||||
// shareAvailable reports whether a share's folder can be exported right now: its owning registered
|
||||
@@ -287,7 +287,7 @@ func (m *Manager) sambaSetPassword(password string) error {
|
||||
var lastErr error
|
||||
// The container may need a moment to accept exec right after `compose up -d`.
|
||||
for attempt := 1; attempt <= 10; attempt++ {
|
||||
cmd := exec.Command("docker", "exec", "-i", sambaContainer,
|
||||
cmd := dockerexec.Command("docker", "exec", "-i", sambaContainer,
|
||||
"smbpasswd", "-s", "-a", infra.SambaHouseholdUser)
|
||||
cmd.Stdin = strings.NewReader(password + "\n" + password + "\n")
|
||||
out, err := cmd.CombinedOutput()
|
||||
@@ -350,7 +350,7 @@ func (m *Manager) sambaLANAddr() (string, error) {
|
||||
if m.sambaAddrFn != nil {
|
||||
return m.sambaAddrFn()
|
||||
}
|
||||
out, err := exec.Command("docker", "exec", sambaContainer,
|
||||
out, err := dockerexec.Command("docker", "exec", sambaContainer,
|
||||
"ip", "-4", "-o", "addr", "show", infra.SambaHostInterface).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docker exec ip addr: %w", err)
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -166,7 +166,7 @@ func guestMemTotalMB() (uint64, bool) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.MemTotal}}").Output()
|
||||
out, err := dockerexec.CommandContext(ctx, "docker", "info", "--format", "{{.MemTotal}}").Output()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -3383,7 +3383,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
s.logger.Printf("[INFO] [web] FileBrowser sources changed — resetting database (restore mode)")
|
||||
resetCtx, resetCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer resetCancel()
|
||||
stop := exec.CommandContext(resetCtx, "docker", "compose", "down", "-v")
|
||||
stop := dockerexec.CommandContext(resetCtx, "docker", "compose", "down", "-v")
|
||||
stop.Dir = stackDir
|
||||
if out, err := stop.CombinedOutput(); err != nil {
|
||||
s.logger.Printf("[WARN] [web] FileBrowser down -v: %s — %v", strings.TrimSpace(string(out)), err)
|
||||
@@ -3399,7 +3399,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
if changed {
|
||||
args = []string{"compose", "up", "-d", "--force-recreate", "--remove-orphans"}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Dir = stackDir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Failed to bring up FileBrowser: %s — %v", string(out), err)
|
||||
|
||||
@@ -288,6 +288,29 @@ func i18nCasesB() []i18nCase {
|
||||
d["Tier2"] = m{"IsHDDApp": false, "Disabled": false, "NoTarget": true}
|
||||
return d
|
||||
}},
|
||||
// R-499 (v0.267.0): the system-disk sentence has one branch per whole-system backup target
|
||||
// state. tier2_not_hdd above carries no SystemBackup, which renders the `unknown` branch.
|
||||
{"tier2_not_hdd_protected", "tier2_config", func() map[string]interface{} {
|
||||
d := i18nLayoutData("backups", "Második mentés beállítása")
|
||||
d["DisplayName"], d["StackName"] = "Private Bin", "privatebin"
|
||||
d["Tier2"] = m{"IsHDDApp": false}
|
||||
d["SystemBackup"] = "protected"
|
||||
return d
|
||||
}},
|
||||
{"tier2_not_hdd_same_disk", "tier2_config", func() map[string]interface{} {
|
||||
d := i18nLayoutData("backups", "Második mentés beállítása")
|
||||
d["DisplayName"], d["StackName"] = "Private Bin", "privatebin"
|
||||
d["Tier2"] = m{"IsHDDApp": false}
|
||||
d["SystemBackup"] = "same_disk"
|
||||
return d
|
||||
}},
|
||||
{"tier2_not_hdd_absent", "tier2_config", func() map[string]interface{} {
|
||||
d := i18nLayoutData("backups", "Második mentés beállítása")
|
||||
d["DisplayName"], d["StackName"] = "Private Bin", "privatebin"
|
||||
d["Tier2"] = m{"IsHDDApp": false}
|
||||
d["SystemBackup"] = "absent"
|
||||
return d
|
||||
}},
|
||||
{"tier2_notarget_disabled", "tier2_config", func() map[string]interface{} {
|
||||
d := i18nLayoutData("backups", "Második mentés beállítása")
|
||||
d["DisplayName"], d["StackName"] = "Immich Photos", "immich"
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-499 — an app on the system disk was told „már szerepelnek a teljes rendszermentésben (PBS)" and
|
||||
// „nincs külön teendő" on every box, including one whose only whole-system copy sat on the SAME disk
|
||||
// (measured 2026-09-14). The page must now say what is true for THIS box.
|
||||
|
||||
// tier2PageServer builds a real Server with one deployed, driveless app ("privatebin") and the
|
||||
// agent's tier/disk answers injected through the production seams, then renders the real handler.
|
||||
func tier2PageServer(t *testing.T, tiers func(context.Context) (agentapi.TiersResponse, error), disks []agentapi.DiskInfo) string {
|
||||
t.Helper()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
||||
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
||||
cfg.Paths.SystemDataPath = filepath.Join(dir, "system")
|
||||
cfg.Stacks.ComposeCommand = "docker compose"
|
||||
cfg.Backup.Enabled = true
|
||||
app := filepath.Join(cfg.Paths.StacksDir, "privatebin")
|
||||
if err := os.MkdirAll(app, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n privatebin:\n image: privatebin/pdo:2.0.6\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(app, "app.yaml"), []byte("deployed: true\n"), 0o600)
|
||||
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sm, err := stacks.NewManager(cfg, lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sm.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bm := backup.NewManager(cfg, sett, lg)
|
||||
bm.SetStackProvider(&blockProvider{hdd: ""}) // driveless: IsHDDApp false
|
||||
s := &Server{cfg: cfg, settings: sett, stackMgr: sm, backupMgr: bm, logger: lg, version: "test"}
|
||||
s.tiersFn = tiers
|
||||
s.disksFn = func(context.Context) (agentapi.DisksResponse, error) { return agentapi.DisksResponse{Disks: disks}, nil }
|
||||
s.loadTemplates()
|
||||
w := httptest.NewRecorder()
|
||||
s.tier2ConfigPageHandler(w, httptest.NewRequest("GET", "/stacks/privatebin/backup", nil), "privatebin")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("page answered %d", w.Code)
|
||||
}
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
func primaryTier(target string) func(context.Context) (agentapi.TiersResponse, error) {
|
||||
return func(context.Context) (agentapi.TiersResponse, error) {
|
||||
return agentapi.TiersResponse{Tiers: []agentapi.BackupTierInfo{{Target: target, Primary: true}}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// COMPANION RED-PROOF: rendering the old sentence for every branch (the pre-fix template) makes the
|
||||
// same_disk case fail on `promises the backup protects this app`.
|
||||
func TestR499_Tier2PageSaysWhatIsTrueForThisBox(t *testing.T) {
|
||||
ownDrive := agentapi.DiskInfo{Name: "mentes", MountPath: "/mnt/mentes", GuestPath: "/mnt/felhom-drives/mentes", Role: "user-data", BackupTarget: true}
|
||||
cases := []struct {
|
||||
name, want string
|
||||
tiers func(context.Context) (agentapi.TiersResponse, error)
|
||||
disks []agentapi.DiskInfo
|
||||
}{
|
||||
{"same disk (the measured box)", "same_disk", primaryTier("local"), nil},
|
||||
{"own drive", "protected", primaryTier("felhom-backup"), []agentapi.DiskInfo{ownDrive}},
|
||||
{"configured drive gone", "absent", primaryTier("felhom-backup"), nil},
|
||||
{"agent not askable", "unknown", func(context.Context) (agentapi.TiersResponse, error) { return agentapi.TiersResponse{}, errors.New("down") }, nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
body := tier2PageServer(t, c.tiers, c.disks)
|
||||
if !strings.Contains(body, `data-system-backup="`+c.want+`"`) {
|
||||
t.Errorf("%s: want branch %q on the page", c.name, c.want)
|
||||
}
|
||||
// ASCII fragments (ui-hungarian rule): "(PBS)" and "nincs k" of „nincs külön teendő".
|
||||
if strings.Contains(body, "(PBS)") {
|
||||
t.Errorf("%s: the page still names PBS, which this box may not have", c.name)
|
||||
}
|
||||
promises := strings.Contains(body, "nincs k")
|
||||
if c.want == "protected" && !promises {
|
||||
t.Errorf("%s: control — the protected branch must still say there is nothing to do", c.name)
|
||||
}
|
||||
if c.want != "protected" && promises {
|
||||
t.Errorf("%s: promises the backup protects this app (\"nothing to do\") on a box where it may not", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-518 — the whole-system backup button promised a short stop; measured 2026-09-14 on a 12-app box,
|
||||
// every app was down for about 7 m 45 s. The copy now states that measurement, in both languages, on
|
||||
// the page and in the confirm dialog. ASCII fragments only (ui-hungarian rule), each with a control.
|
||||
func TestR518_BackupButtonStatesTheMeasuredDowntime(t *testing.T) {
|
||||
s := i18nTestServer(t)
|
||||
var c i18nCase
|
||||
for _, x := range i18nCases() {
|
||||
if x.name == "backups_full" {
|
||||
c = x
|
||||
}
|
||||
}
|
||||
if c.name == "" {
|
||||
t.Fatal("no backups_full case — the test would pass by looking at nothing")
|
||||
}
|
||||
for _, lang := range []string{"hu", "en"} {
|
||||
body := renderI18nCase(t, s, lang, c)
|
||||
measured, vague := "8 perc", "" // „kb. 8 perc"
|
||||
if lang == "en" {
|
||||
measured, vague = "about 8 minutes", "usually for a few minutes"
|
||||
}
|
||||
if n := strings.Count(body, measured); n < 2 {
|
||||
t.Errorf("%s: the measured downtime appears %d times, want it on the page AND in the confirm", lang, n)
|
||||
}
|
||||
if lang == "en" && strings.Contains(body, vague) {
|
||||
t.Errorf("en: the old vague promise %q is still on the page", vague)
|
||||
}
|
||||
if lang == "hu" && strings.Contains(body, "ltalában néhány perc, nagyobb") {
|
||||
t.Errorf("hu: the old vague promise is still on the page")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
)
|
||||
|
||||
// TestMain puts a silent docker stub on PATH for every test in this package: R-650, the fixtures
|
||||
// here build a real stacks.Manager, and on the build host (DooPlex) that reached production Docker.
|
||||
func TestMain(m *testing.M) { os.Exit(dockerexec.RunWithStub(m)) }
|
||||
@@ -16,9 +16,23 @@
|
||||
</p>
|
||||
|
||||
{{if not .IsHDDApp}}
|
||||
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem">
|
||||
{{T "tier2_config.ennek_az_alkalmazasnak_az_adatai"}}
|
||||
{{if eq $.SystemBackup "protected"}}
|
||||
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="protected">
|
||||
{{T "tier2_config.rendszerlemez.vedett"}}
|
||||
</div>
|
||||
{{else if eq $.SystemBackup "same_disk"}}
|
||||
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="same_disk">
|
||||
{{T "tier2_config.rendszerlemez.ugyanaz"}}
|
||||
</div>
|
||||
{{else if eq $.SystemBackup "absent"}}
|
||||
<div class="monitoring-banner monitoring-banner-red" style="margin-top:1rem" data-system-backup="absent">
|
||||
{{T "tier2_config.rendszerlemez.hianyzik"}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="unknown">
|
||||
{{T "tier2_config.rendszerlemez.ismeretlen"}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<h3 style="margin-top:1.5rem">{{T "tier2_config.jelenlegi_allapot"}}</h3>
|
||||
<div class="sysinfo-grid">
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
|
||||
function triggerGuestBackup() {
|
||||
var btn = document.getElementById('wg-backup-btn');
|
||||
felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.', function () {
|
||||
felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () {
|
||||
var out = document.getElementById('wg-backup-result');
|
||||
btn.disabled = true;
|
||||
out.innerHTML = '<span class="form-hint">Mentés indítása…</span>';
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
|
||||
function triggerGuestBackup() {
|
||||
var btn = document.getElementById('wg-backup-btn');
|
||||
felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.', function () {
|
||||
felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () {
|
||||
var out = document.getElementById('wg-backup-result');
|
||||
btn.disabled = true;
|
||||
out.innerHTML = '<span class="form-hint">Mentés indítása…</span>';
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
|
||||
<div class="schedule-actions" style="margin-top:1rem">
|
||||
<button class="btn btn-sm btn-primary" id="wg-backup-btn" onclick="triggerGuestBackup()" disabled>Mentés most</button>
|
||||
<span class="form-hint" style="margin-left:.5rem"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.</span>
|
||||
<span class="form-hint" style="margin-left:.5rem"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.</span>
|
||||
<div id="wg-backup-result" style="margin-top:.6rem"></div>
|
||||
</div>
|
||||
|
||||
@@ -404,7 +404,7 @@
|
||||
|
||||
function triggerGuestBackup() {
|
||||
var btn = document.getElementById('wg-backup-btn');
|
||||
felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.', function () {
|
||||
felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () {
|
||||
var out = document.getElementById('wg-backup-result');
|
||||
btn.disabled = true;
|
||||
out.innerHTML = '<span class="form-hint">Mentés indítása…</span>';
|
||||
|
||||
@@ -289,7 +289,7 @@
|
||||
|
||||
function triggerGuestBackup() {
|
||||
var btn = document.getElementById('wg-backup-btn');
|
||||
felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.', function () {
|
||||
felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () {
|
||||
var out = document.getElementById('wg-backup-result');
|
||||
btn.disabled = true;
|
||||
out.innerHTML = '<span class="form-hint">Mentés indítása…</span>';
|
||||
|
||||
@@ -251,7 +251,7 @@
|
||||
|
||||
<div class="schedule-actions" style="margin-top:1rem">
|
||||
<button class="btn btn-sm btn-primary" id="wg-backup-btn" onclick="triggerGuestBackup()" >Mentés most</button>
|
||||
<span class="form-hint" style="margin-left:.5rem"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.</span>
|
||||
<span class="form-hint" style="margin-left:.5rem"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.</span>
|
||||
<div id="wg-backup-result" style="margin-top:.6rem"></div>
|
||||
</div>
|
||||
|
||||
@@ -312,7 +312,7 @@
|
||||
|
||||
function triggerGuestBackup() {
|
||||
var btn = document.getElementById('wg-backup-btn');
|
||||
felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt az alkalmazások leállnak — általában néhány perc, nagyobb adatnál több.', function () {
|
||||
felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () {
|
||||
var out = document.getElementById('wg-backup-result');
|
||||
btn.disabled = true;
|
||||
out.innerHTML = '<span class="form-hint">Mentés indítása…</span>';
|
||||
|
||||
@@ -184,13 +184,13 @@
|
||||
</p>
|
||||
|
||||
|
||||
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem">
|
||||
Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek
|
||||
<strong>már szerepelnek a teljes rendszermentésben (PBS)</strong>. A 2. (off-drive) másolat
|
||||
kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az
|
||||
alkalmazáshoz nincs külön teendő.
|
||||
|
||||
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="unknown">
|
||||
Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, és a teljes rendszermentés része.
|
||||
Hogy a rendszermentés most hová készül, azt nem tudtuk lekérdezni — a Mentések oldalon láthatod.
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
|
||||
|
||||
<!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>Második mentés beállítása — 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,'&').replace(/</g,'<').replace(/>/g,'>')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
|
||||
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
|
||||
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
|
||||
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
|
||||
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
|
||||
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
|
||||
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
|
||||
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
|
||||
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
|
||||
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
|
||||
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
|
||||
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
|
||||
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
|
||||
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
|
||||
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
|
||||
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
|
||||
</svg>
|
||||
<header class="mobile-topbar">
|
||||
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
|
||||
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
|
||||
<svg class="ico"><use href="#i-menu"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="nav-backdrop" hidden></div>
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
|
||||
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
|
||||
<li><a href="/stacks" class=""><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 is-open">
|
||||
<button type="button" class="nav-group-toggle active" aria-expanded="true" 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="active">Á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>2. mentés beállítása — Private Bin</h2>
|
||||
<a href="/backups/apps" class="btn btn-outline btn-sm">← Vissza a mentésekhez</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="monitor-card">
|
||||
|
||||
<p style="color:var(--text-3);font-size:.9rem;margin-top:0">
|
||||
A 2. mentés egy <strong>másik fizikai meghajtóra</strong> készít másolatot az alkalmazás
|
||||
helyreállítási csomagjáról és adatairól. Ez az egyetlen off-drive védelem a böngészhető
|
||||
felhasználói fájlokhoz (a teljes rendszermentés/PBS nem éri el ezeket).
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<div class="monitoring-banner monitoring-banner-red" style="margin-top:1rem" data-system-backup="absent">
|
||||
Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak. A teljes rendszermentés meghajtója
|
||||
<strong>most nem érhető el</strong> — amíg vissza nem csatlakoztatod, erről az alkalmazásról sem készül friss rendszermentés.
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</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>';
|
||||
}
|
||||
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 = '';
|
||||
|
||||
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 (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 (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>
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
|
||||
|
||||
<!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>Második mentés beállítása — 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,'&').replace(/</g,'<').replace(/>/g,'>')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
|
||||
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
|
||||
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
|
||||
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
|
||||
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
|
||||
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
|
||||
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
|
||||
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
|
||||
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
|
||||
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
|
||||
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
|
||||
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
|
||||
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
|
||||
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
|
||||
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
|
||||
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
|
||||
</svg>
|
||||
<header class="mobile-topbar">
|
||||
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
|
||||
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
|
||||
<svg class="ico"><use href="#i-menu"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="nav-backdrop" hidden></div>
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
|
||||
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
|
||||
<li><a href="/stacks" class=""><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 is-open">
|
||||
<button type="button" class="nav-group-toggle active" aria-expanded="true" 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="active">Á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>2. mentés beállítása — Private Bin</h2>
|
||||
<a href="/backups/apps" class="btn btn-outline btn-sm">← Vissza a mentésekhez</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="monitor-card">
|
||||
|
||||
<p style="color:var(--text-3);font-size:.9rem;margin-top:0">
|
||||
A 2. mentés egy <strong>másik fizikai meghajtóra</strong> készít másolatot az alkalmazás
|
||||
helyreállítási csomagjáról és adatairól. Ez az egyetlen off-drive védelem a böngészhető
|
||||
felhasználói fájlokhoz (a teljes rendszermentés/PBS nem éri el ezeket).
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="protected">
|
||||
Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek
|
||||
<strong>szerepelnek a teljes rendszermentésben</strong>, és az egy külön meghajtóra készül. A 2. (off-drive) másolat
|
||||
kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az
|
||||
alkalmazáshoz nincs külön teendő.
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</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>';
|
||||
}
|
||||
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 = '';
|
||||
|
||||
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 (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 (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>
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
|
||||
|
||||
<!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>Második mentés beállítása — 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,'&').replace(/</g,'<').replace(/>/g,'>')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
|
||||
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
|
||||
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
|
||||
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
|
||||
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
|
||||
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
|
||||
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
|
||||
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
|
||||
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
|
||||
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
|
||||
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
|
||||
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
|
||||
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
|
||||
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
|
||||
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
|
||||
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
|
||||
</svg>
|
||||
<header class="mobile-topbar">
|
||||
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
|
||||
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
|
||||
<svg class="ico"><use href="#i-menu"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="nav-backdrop" hidden></div>
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
|
||||
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
|
||||
<li><a href="/stacks" class=""><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 is-open">
|
||||
<button type="button" class="nav-group-toggle active" aria-expanded="true" 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="active">Á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>2. mentés beállítása — Private Bin</h2>
|
||||
<a href="/backups/apps" class="btn btn-outline btn-sm">← Vissza a mentésekhez</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="monitor-card">
|
||||
|
||||
<p style="color:var(--text-3);font-size:.9rem;margin-top:0">
|
||||
A 2. mentés egy <strong>másik fizikai meghajtóra</strong> készít másolatot az alkalmazás
|
||||
helyreállítási csomagjáról és adatairól. Ez az egyetlen off-drive védelem a böngészhető
|
||||
felhasználói fájlokhoz (a teljes rendszermentés/PBS nem éri el ezeket).
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="same_disk">
|
||||
Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak. A teljes rendszermentés
|
||||
<strong>jelenleg ugyanerre a lemezre készül</strong> — így hibás fájlok ellen véd, lemezhiba ellen nem.
|
||||
Ha csatlakoztatsz egy második meghajtót, és a Mentések oldalon kijelölöd a rendszermentés helyéül, ez az alkalmazás is védett lesz.
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</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>';
|
||||
}
|
||||
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 = '';
|
||||
|
||||
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 (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 (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>
|
||||
|
||||
@@ -34,6 +34,11 @@ func (s *Server) tier2ConfigPageHandler(w http.ResponseWriter, r *http.Request,
|
||||
data["StackName"] = name
|
||||
data["DisplayName"] = stack.Meta.DisplayName
|
||||
data["Tier2"] = info
|
||||
if !info.IsHDDApp {
|
||||
// R-499: the sentence about a system-disk app must say where THIS box's whole-system backup
|
||||
// goes. It used to promise „már szerepelnek a teljes rendszermentésben (PBS)" on every box.
|
||||
data["SystemBackup"] = systemBackupFact(s.resolveBackupTargetState(r.Context()))
|
||||
}
|
||||
if flash := s.flashFrom(r, "flash"); flash != "" {
|
||||
data["Flash"] = flash
|
||||
}
|
||||
@@ -133,3 +138,27 @@ func (s *Server) redirectTier2(w http.ResponseWriter, r *http.Request, name, fla
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// systemBackupFact reduces the whole-system backup's target state to the one fact the Tier-2 page
|
||||
// states about an app on the system disk (R-499). The page used to tell every such app that its data
|
||||
// was „already in the full system backup (PBS)" and there was nothing to do — measured false on a box
|
||||
// whose only whole-system copy sat on the same disk (2026-09-14). Four states, four sentences:
|
||||
//
|
||||
// protected — the whole-system backup goes to a drive of its own
|
||||
// same_disk — it goes to the system disk itself: protects against bad files, not a dead disk
|
||||
// absent — its drive is configured and gone: no fresh whole-system backup is being made
|
||||
// unknown — the agent could not be asked: say so, promise nothing
|
||||
//
|
||||
// Pinned by TestR499_* (the mapping and one render per branch).
|
||||
func systemBackupFact(st BackupTargetState) string {
|
||||
switch {
|
||||
case !st.Known:
|
||||
return "unknown"
|
||||
case st.TargetAbsent:
|
||||
return "absent"
|
||||
case st.Degraded:
|
||||
return "same_disk"
|
||||
default:
|
||||
return "protected"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,9 @@
|
||||
"event.app_update_undone": "BORN AS A KEY, v0.264.0 (R-606 / the update events) -- a NEW sentence, never a Go literal.",
|
||||
"settings_notifications.app_update_undone": "BORN AS A KEY, v0.264.0 (R-606 / the update events) -- a NEW sentence, never a Go literal.",
|
||||
"settings_notifications.app_update_held": "BORN AS A KEY, v0.264.0 (R-606 / the update events) -- a NEW sentence, never a Go literal.",
|
||||
"badge.update.held": "BORN AS A KEY, v0.265.0 (R-625) -- a NEW badge for a held app; its Hungarian twin in updatebadge.go is pinned by TestR625_HeldBadgeHungarianMatchesTheBundle."
|
||||
"badge.update.held": "BORN AS A KEY, v0.265.0 (R-625) -- a NEW badge for a held app; its Hungarian twin in updatebadge.go is pinned by TestR625_HeldBadgeHungarianMatchesTheBundle.",
|
||||
"err.backup.adatbazis_masolat_csonka_nem_indult": "BORN AS A KEY, v0.267.0 (R-640) -- a NEW refusal for a cut-off database copy; pinned by TestR640_*.",
|
||||
"err.backup.adatbazis_masolat_csonka_nem_toltve": "BORN AS A KEY, v0.267.0 (R-640) -- a NEW refusal for a cut-off database copy; pinned by TestR640_*."
|
||||
},
|
||||
"flash.share.already_on": "A megosztás már be van kapcsolva.",
|
||||
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
||||
|
||||
Reference in New Issue
Block a user