v0.317.0: a second copy counts only on a proven different disk; the no-drive install speaks in words (felhom.eu architecture/16, build A)
gates / gates (push) Successful in 46s
gates / gates (push) Successful in 46s
Tier-2 and the one-copy notice ask the host agent (POST /disks/same-disk, agent 0.157.0): different + proven -> made and counted; same disk -> not made (D1); unknown (VM, hidden USB serial) -> made, not counted, the row says why (D2); a NAS never counts and no longer hides the notice (R-1075). An older agent keeps the st_dev rule, logged once. D4: a data-folder or media app on a box with no drive is refused in words with a link to the drives page (R-1068's raw-error half). MinAgent 0.157.0. Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
@@ -1,3 +1,24 @@
|
|||||||
|
## v0.317.0 — a second copy counts only on a proven different disk; the no-drive install speaks in words (2026-10-15)
|
||||||
|
|
||||||
|
**MinAgent: 0.157.0** — the same-disk verdict needs agent 0.157.0 (`FeatureSameDisk`, `POST /disks/same-disk`). Below it the
|
||||||
|
box keeps exactly today's behaviour (the st_dev rule), logged once — so the image is safe on every box, but the global floor
|
||||||
|
waits for the operator's vouch of agent 0.157.0. No golden (`09` §3 decision 250). Design: felhom.eu
|
||||||
|
`architecture/16-storage-physical-identity.md` §4, §5 (D4 only), §8, §10 — operator rulings D1–D6, 2026-10-15.
|
||||||
|
|
||||||
|
- **Tier-2 asks the agent** (`backup.Manager.diskRelation`): different + proven → made and counted; **same disk → not made**
|
||||||
|
(`16` D1); **unknown** (a VM, a USB case that hides the serial) → **made, not counted** (`16` D2) — the app's row says „A Felhom
|
||||||
|
nem tudja ellenőrizni, hogy ez a meghajtó valóban másik lemez-e…"; a NAS → never a target. A proven different drive beats an
|
||||||
|
unknown one. The verdict is asked again right before each run.
|
||||||
|
- **The one-copy notice** (`/backups`) follows the same rule and moved to i18n, informal voice (R-1072's half): „Csak egy helyi
|
||||||
|
másolat készül: nincs második lemez, vagy ugyanazon a lemezen van, mint az adatok…" or, for an unknown second drive, „A Felhom
|
||||||
|
nem tudja ellenőrizni…". **A NAS no longer hides it** (R-1075).
|
||||||
|
- **D4 (R-1068's raw-error half):** a data-folder or media app on a box with no drive is refused with „Ehhez az alkalmazáshoz egy
|
||||||
|
adattároló kell. A Meghajtók oldalon adhatsz hozzá egyet." and a link (API 400, `code: needs_drive`) — never the field name.
|
||||||
|
- Tests: the four `16` §4 rows, an older agent, the recorded mark, the D4 sentence (both an app with and without the media marker),
|
||||||
|
the wiring in main.go — each red-proofed. Ten i18n parity pages refreshed: the only change is the new lines.
|
||||||
|
- **Not changed, reported instead:** the drives page still offers `/mnt/sys_drive` for attaching (R-280, the reinstall-restore
|
||||||
|
way out); the design rejects it for media (`16` §11). The two disagree — filed for the operator as R-1082, not decided here.
|
||||||
|
|
||||||
## v0.316.4 — the clean-tree gate is a mechanism, not a sentence (2026-10-15)
|
## v0.316.4 — the clean-tree gate is a mechanism, not a sentence (2026-10-15)
|
||||||
|
|
||||||
**MinAgent: 0.155.0** (unchanged). **Do not deploy `0.316.3`** — see below.
|
**MinAgent: 0.155.0** (unchanged). **Do not deploy `0.316.3`** — see below.
|
||||||
|
|||||||
@@ -1,177 +1,51 @@
|
|||||||
# REPORT — v0.316.x: the app page as one column; one image per version number (2026-10-15)
|
# REPORT — v0.317.0: a second copy counts only on a proven different disk; the no-drive install speaks in words (2026-10-15)
|
||||||
|
|
||||||
|
Written in ASD-STE100 Simplified Technical English. Design read first: felhom.eu `architecture/16-storage-physical-identity.md`
|
||||||
|
(§3, §4, §5 D4, §8, §10, §11), `07-backup-architecture.md` §6, `09-update-architecture.md` (MinAgent), `03-host-agent.md`.
|
||||||
|
Task: storage identity build A (operator rulings D1–D6, 2026-10-15). Agent counterpart: felhom-agent v0.157.0.
|
||||||
|
|
||||||
## 1. Baselines
|
## 1. Baselines
|
||||||
|
|
||||||
| Repo | `main` at start | Version | → |
|
| Repo | `main` at start | at commit |
|
||||||
|---|---|---|---|
|
|
||||||
| felhom-controller | `8339206` | v0.315.0 | **v0.316.0 → .1 → .2 built; .3 committed, NOT built (§6)** |
|
|
||||||
| felhom.eu | `340804f9` (docs only) | — | — |
|
|
||||||
|
|
||||||
Architecture read first: `02-controller-module-map.md` „The app page", `09-update-architecture.md`,
|
|
||||||
the `felhom-ui-design` skill, and the controller's `CLAUDE.md` + `.claude/rules/*`.
|
|
||||||
|
|
||||||
Built and tested on DooPlex in an isolated scratch clone; edited on the Windows workstation. The
|
|
||||||
browser work is on the workstation, which is the reason CC runs here.
|
|
||||||
|
|
||||||
## 2. PART 0 — the diagnosis, before any fix
|
|
||||||
|
|
||||||
**Verdict: (a) a cache.** Full evidence: `felhom.eu/documentation/audits/app-page-2026-10-15b/P0/VERDICT.md`.
|
|
||||||
|
|
||||||
**(b) is ruled out.** Exactly one guest ran 0.315.0 — demo-hp 9201 — on digest
|
|
||||||
`sha256:8887343011d6…`, identical to the registry's 0.315.0 at the time of measuring. The **global
|
|
||||||
floor is 0.314.0** (read from the hub's `/configuration`), so no box could have taken 0.315.0 by
|
|
||||||
itself. demo-hp 9202 and felhom-pve (N100) 9201 are both on 0.314.0; Tester 1 (`c11-appliance`) was
|
|
||||||
unreachable and, by the floor, cannot be on 0.315.0.
|
|
||||||
|
|
||||||
**The mechanism, in four measured facts:**
|
|
||||||
|
|
||||||
1. `layout.html` asked for `/static/style.css?v={{.Version}}` → `?v=0.315.0` for **all three**
|
|
||||||
different 0.315.0 builds (`e66d466`, `fe0228c`, `db52f5a`); the first had no `.app-tabs` rules.
|
|
||||||
2. The origin sends **no validator**: measured on the box, `ETag present = 0`,
|
|
||||||
`Last-Modified present = 0`. A cached copy cannot be revalidated — only expire.
|
|
||||||
3. Cloudflare caches that URL (`cf-cache-status: HIT`, `age: 99 → 100` over three requests) and
|
|
||||||
**rewrites the browser TTL from the origin's `max-age=3600` to `max-age=14400`** — four hours.
|
|
||||||
4. The session's first request returned **`cf-cache-status: EXPIRED`**, which only happens when the
|
|
||||||
edge holds an entry that has aged past its TTL.
|
|
||||||
|
|
||||||
**What is true now, and the caveat stated rather than glossed:** origin, edge and box agree
|
|
||||||
byte-for-byte (`sha256 c1774f63…` both sides) and all carry the rules; the page itself is never
|
|
||||||
cached (`DYNAMIC`). The specific stale object is **no longer observable** — the first probe forced
|
|
||||||
its revalidation, which is what `EXPIRED` records — so I cannot show a `HIT` whose body lacks
|
|
||||||
`.app-tabs`. What is shown is every precondition, the proof an aged entry existed, and the
|
|
||||||
elimination of the only other candidate. No cache was purged by hand.
|
|
||||||
|
|
||||||
## 3. Boxes on 0.315.0, with digests
|
|
||||||
|
|
||||||
| Box | tag | digest | now |
|
|
||||||
|---|---|---|---|
|
|
||||||
| demo-hp 9201 | 0.315.0 | `8887343011d6` | **0.316.2** |
|
|
||||||
| demo-hp 9202 | 0.314.0 | — | unchanged |
|
|
||||||
| felhom-pve (N100) 9201 | 0.314.0 | — | unchanged |
|
|
||||||
| Tester 1 (`c11-appliance`) | unreachable | — | cannot be on 0.315.0 (floor 0.314.0) |
|
|
||||||
|
|
||||||
## 4. Files changed, commits on `main`
|
|
||||||
|
|
||||||
| Commit | What |
|
|
||||||
|---|---|
|
|
||||||
| `9cc7ffc` | build.sh refuses a repeated push; `assetTag` per build; every shell uses it; harness normalises it |
|
|
||||||
| `7f3e58f` | the one-column layout (the approved mockup) |
|
|
||||||
| `48d8674` | v0.316.0 CHANGELOG + the push-once fact in the rule file |
|
|
||||||
| `006ceab` | **v0.316.1** — the asset tag reaches the dashboard pages |
|
|
||||||
| `f908eee` | the settings tab loses the install page's boxes |
|
|
||||||
| `9e68e52` | two more data-tab leftovers |
|
|
||||||
| `8c7aa10` | **v0.316.3** CHANGELOG |
|
|
||||||
| felhom.eu `b6638ac4`, `22cd7c51` | the design docs, the audit, R-1080 closed, R-1081 filed |
|
|
||||||
|
|
||||||
Main files: `controller/build.sh`, `scripts/test_build_push_once.sh`, `internal/web/server.go`,
|
|
||||||
`asset_tag_test.go`, `app_layout_test.go`, `templates/{style.css,app_shell,app_settings,app_data,app_guide,app_logs,app_fields,deploy}.html`,
|
|
||||||
both locale files, `i18n_parity_test.go`, `i18n_wiring_test.go`.
|
|
||||||
|
|
||||||
## 5. Tests and red-proofs
|
|
||||||
|
|
||||||
| # | Break | Result |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| B1 | the whole registry check deleted | 5 of 6 cases fail; a present tag builds AND pushes |
|
| felhom-controller | `9cc7ffcf3b` (brief) → `48d8674876` read | rebased onto `c6a5671` (v0.316.4) |
|
||||||
| B2 | an unreachable registry treated as absent | the two unanswered-registry cases fail |
|
| felhom-agent | `d2053e7d47` | `52ed52c` (v0.157.0 released) |
|
||||||
| C1 | `assetTag` from the version only | `TestAssetTag_ChangesPerBuild` fails on both same-version cases |
|
|
||||||
| C2 | one shell back on `?v={{.Version}}` | `TestAssetTag_EveryShellUsesIt` fails naming layout.html |
|
|
||||||
| C3 | `addShellAssetData` removed from `executeTemplate` | `TestAssetTag_ReachesTheRenderedPage` fails: empty buster on four pages |
|
|
||||||
| D1 | the auto-update success banner restored | `TestAppPage_AutoUpdateSuccessIsMetaNotBanner` fails |
|
|
||||||
| D2 | one tab loses its column | `TestAppPage_OneColumnLayout` fails naming the tab |
|
|
||||||
| D3 | a section drawn as a card again | same test fails on two tabs |
|
|
||||||
| E | an informational section drawn as a blue callout | `TestAppPage_CalloutsOnlyForAskAndWarn` fails on both counts |
|
|
||||||
|
|
||||||
New: `TestBuildScript_RefusesExistingTag` (six cases, real script, fake docker),
|
## 2. What changed
|
||||||
`TestAssetTag_{ChangesPerBuild,EveryShellUsesIt,ReachesTheRenderedPage}`,
|
|
||||||
`TestAppPage_{OneColumnLayout,AutoUpdateSuccessIsMetaNotBanner,CalloutsOnlyForAskAndWarn}`.
|
|
||||||
|
|
||||||
Full gate: `go build ./... && go vet ./... && go test ./...` → **exit 0, 37 packages ok, zero
|
- `internal/backup/backup.go`: `diskRelation` — the one „same disk?" decision; asks the agent through `SetDiskJudge`; an older
|
||||||
failures**, measured with `go test`'s own exit code (not a pipeline's). All controller gates OK; 53
|
agent or an `unmapped` path keeps the st_dev rule (logged once). `offDriveCopyState` / `singleCopyWarning`: the notice by `16` §4,
|
||||||
decoys behaved.
|
skips a NAS (R-1075), i18n.
|
||||||
|
- `internal/backup/tier2.go`, `tier2_shares.go`: target choice — same disk never; unknown made, marked `Unproven`; proven beats unknown;
|
||||||
|
the guard re-asks right before the run. `settings.CrossDriveBackup.Unproven` records it; `handlers.go` + `backups_apps.html` show it.
|
||||||
|
- `internal/agentapi`: `FeatureSameDisk` (MinAgent 0.157.0), `Client.SameDisk`, the identity fields on `DiskInfo`.
|
||||||
|
- `internal/stacks/deploy.go`, `deploy_errors.go`, `internal/api/router.go`, `deploy.html`: D4 — `ErrNeedsDrive`, 400, `code: needs_drive`,
|
||||||
|
the sentence and a link to the drives page.
|
||||||
|
- `cmd/controller/main.go`: the judge wired. Locales: four new keys in both files; `scripts/i18n_go_keys.json` lists them.
|
||||||
|
|
||||||
## 6. Deployed — and what is NOT
|
## 3. Tests and red-proofs
|
||||||
|
|
||||||
**demo-hp guest 9201 runs `0.316.2`, healthy.** Tester 1 and demo-felhom untouched. The **floor stays
|
| Test | Red-proof (seen failing) |
|
||||||
0.314.0** — raising it needs all three test boxes and a green box walk.
|
|---|---|
|
||||||
|
| `TestDisk16_ProvenDifferent_CountedNoNotice` | — (positive control for the others) |
|
||||||
|
| `TestDisk16_SameDisk_NotMadeAndNoticeShown` | the same-disk refusal turned off → fails |
|
||||||
|
| `TestDisk16_Unknown_MadeNotCountedCannotCheck` | the unproven mark set false → fails |
|
||||||
|
| `TestDisk16_NetworkShare_NeverHidesTheNotice` | the NAS skip removed → fails (R-1075) |
|
||||||
|
| `TestDisk16_OldAgent_KeepsTheStDevRule` | fails with the same-disk refusal off |
|
||||||
|
| `TestDisk16_UnprovenCopyIsRecorded` | — |
|
||||||
|
| `TestD4_NoDriveRefusalSpeaksInWords` | the D4 branch off → fails with the OMV run's raw sentence |
|
||||||
|
| `TestD4_NeedsDriveIs400`, `TestD4_FilledPathKeepsItsOwnRefusal` | — |
|
||||||
|
| `TestDiskJudge_IsWiredAtStartup` | the judge made to never call the agent → fails |
|
||||||
|
|
||||||
**v0.316.3 is committed and pushed to git but NOT built.** Between the 0.316.2 build and this one,
|
`go test ./...` green; `controller_gates.py --fast` green except `observations` (this section fixes it). Ten i18n parity pages
|
||||||
another session's work in progress appeared in the shared DooPlex build clone (15 modified files,
|
refreshed with `-update-i18n-golden` after moving the old ones aside; the diff of each is ONLY the new lines (the stylesheet line
|
||||||
the physical-disk storage work, R-1074..R-1079). The clean-tree gate forbids building over it and I
|
kept as stored).
|
||||||
will not stash or revert another session's work. Every build I did make printed `porcelain=0` first.
|
|
||||||
|
|
||||||
What 0.316.3 adds, and therefore what the operator will NOT see tonight: on the data tab,
|
## 4. Live proof
|
||||||
„Biztonsági mentés" is still drawn as a card, and the 2nd-copy heading still reads „2. mentés
|
|
||||||
beállítása — AdventureLog". Both are cosmetic; everything else is live.
|
|
||||||
|
|
||||||
## 7. The browser check, through the PUBLIC address
|
Pending at this commit — the next REPORT revision carries it (Tester 1, demo-hp, the N100, a NAS on a scratch box).
|
||||||
|
|
||||||
Chrome on the workstation, `https://felhom.enkisfelhom.hu`, **normal loads — no hard refresh**:
|
|
||||||
|
|
||||||
- The stylesheet URL is `?v=20540d81` (an 8-hex per-build tag, not a version, not empty), and the
|
|
||||||
loaded sheet carries the `.app-tabs` rules. **This is the fix working:** before it, the same page
|
|
||||||
needed a hand-forced refetch.
|
|
||||||
- `.app-tab-active` computes `border-bottom: 2px rgb(46,168,245)` = `--blue-bright`; the column is
|
|
||||||
`max-width: 820px`; the hero has `border-top-width: 0px`.
|
|
||||||
- Across settings / data / guide / logs / plex / grocy / install: **1 column each, 1 `aria-current`
|
|
||||||
on an installed app and 0 on a not-installed one, 0 `.app-info-card`, 0 success banners.**
|
|
||||||
- Data tab: exactly **1 amber callout** (the same-disk warning). Settings, guide, grocy: **0 blue,
|
|
||||||
0 amber** — correct, nothing is asked and nothing is risky.
|
|
||||||
- **Console clean on a fresh load.**
|
|
||||||
|
|
||||||
Screenshots: `felhom.eu/documentation/audits/app-page-2026-10-15b/browser/`.
|
|
||||||
|
|
||||||
**Scenario E's blue callout was NOT exercised live.** Plex's setup gate is open (the operator's real
|
|
||||||
claim, R-1060), so `Needed()` is false and the card is correctly hidden. Showing it live would mean
|
|
||||||
un-claiming the operator's real Plex server; not done. The branch is covered by
|
|
||||||
`TestAppPage_CalloutsOnlyForAskAndWarn` against a harness Plex with a closed gate.
|
|
||||||
|
|
||||||
## 8. Fixtures replaced, by name
|
|
||||||
|
|
||||||
Recaptured (the pages this task restyles, per the standing exception): `app_settings_*` ×21,
|
|
||||||
`app_data_*` ×9, `app_guide_*` ×4, `app_logs`, `deploy_new{,_blocked,_memory_blocked}`. **All other
|
|
||||||
fixtures are byte-identical** — and now permanently so against build identity: the harness
|
|
||||||
normalises the asset tag on both sides of the comparison, as it already did for the per-session CSRF
|
|
||||||
token, so a build can no longer churn 148 files.
|
|
||||||
|
|
||||||
## 9. NOT yet validated
|
|
||||||
|
|
||||||
1. **The operator's look** at AdventureLog and Plex on demo-hp through the public address — asked
|
|
||||||
for in STATUS. The layout judgement is theirs.
|
|
||||||
2. **v0.316.3's two cosmetic fixes** (§6) — committed, unbuilt, so unseen.
|
|
||||||
3. **A blue callout in a real browser** (§7).
|
|
||||||
|
|
||||||
## 10. Teardown — three layers
|
|
||||||
|
|
||||||
- **Machine:** nothing provisioned, no app installed, no throwaway Plex. Probe scripts and fetched
|
|
||||||
pages removed from DooPlex `/tmp`.
|
|
||||||
- **Host:** the scratch clone and the `felhom.eu` sibling symlink are deleted. The shared DooPlex
|
|
||||||
checkout was never written to by me — which is how I could tell the other session's work from
|
|
||||||
mine. `build.sh` in the build directory is now a symlink to the repo's (deliberate, §11), with the
|
|
||||||
stale copy kept beside it.
|
|
||||||
- **Hub:** nothing created; no floor change.
|
|
||||||
|
|
||||||
## 11. Observations
|
## 11. Observations
|
||||||
|
|
||||||
- **FILED: R-1081** — the registry's `0.315.0` no longer holds the 0.315.0 release. **I did this**,
|
1. FILED: R-1082 — the drives page offers `/mnt/sys_drive` for attaching (R-280, the reinstall-restore way out) while `16` §11 rejects
|
||||||
and the cause is worth stating plainly: I put the guard in `controller/build.sh` in the repo, but
|
it for household data, and the D4 sentence links to that page. Not changed here: source and design disagree, so it is reported.
|
||||||
the documented release command runs `$FELHOM_ROOT/build/felhom-controller/build.sh`, which was a
|
|
||||||
**separate copy taken 2026-07-18**. The very first thing I did with the new refusal — run it
|
|
||||||
against a real existing tag to prove it fires — therefore rebuilt and re-pushed 0.315.0 a fourth
|
|
||||||
time, with v0.316.0's code under it. A guard added to a script the runbook does not execute is the
|
|
||||||
seam-built-but-never-wired class, and I shipped one while fixing another. Root cause fixed in the
|
|
||||||
session (symlink), then the refusal proven from the documented path. No box is affected.
|
|
||||||
- **NOT-A-FINDING (fixed in session):** the asset tag shipped dead in 0.316.0 — every shell asked for
|
|
||||||
it, the template test passed, and the dashboard path never set it, so the first live load rendered
|
|
||||||
`?v=` (empty, i.e. a constant). Found in a browser; `TestAssetTag_ReachesTheRenderedPage` now
|
|
||||||
asserts the rendered value, not the markup.
|
|
||||||
- **NOT-A-FINDING (fixed in session):** `app_row_dedup_gate` convicted my `.app-row`, correctly —
|
|
||||||
that class already means „a row in the app list". Renamed `.app-field-row`; an over-broad rename
|
|
||||||
briefly took the list's own rule with it and was restored.
|
|
||||||
- **NOT-A-FINDING:** `page.title.tier2_config`-style leftovers — the 2nd-copy heading's locale key
|
|
||||||
carries „— {{.DisplayName}}" because it was a page title. New key `app_page.second_backup`.
|
|
||||||
- **Register: 169 before → 170 after; 1 opened (R-1081), 1 opened-and-closed (R-1080).**
|
|
||||||
- **System poster:** no fact in `felhom-system-poster.facts.md` changed. Neither file edited;
|
|
||||||
nothing added to STATUS about it.
|
|
||||||
- **Instruction files:** `.claude/rules/unprompted-work.md` item 6 in **all four** versioned copies
|
|
||||||
gains „a version number is pushed once", stated as a fact about what the build now enforces, not
|
|
||||||
as a new rule. The workspace-root copy is unversioned and not on this machine.
|
|
||||||
|
|||||||
@@ -214,6 +214,7 @@
|
|||||||
| `registerStoragePath` | controller/internal/web/storage_handlers.go | `(where, label, setDefault) error` | Post-enroll registration | The single funnel used by init/attach/manual-add |
|
| `registerStoragePath` | controller/internal/web/storage_handlers.go | `(where, label, setDefault) error` | Post-enroll registration | The single funnel used by init/attach/manual-add |
|
||||||
| `system.IsMountPoint` / `IsWritable` / `PathsOverlap` | controller/internal/system/mounts_linux.go | `(path) bool` | Mount checks | `_other.go` stubs return permissive values — Linux behavior is the real one |
|
| `system.IsMountPoint` / `IsWritable` / `PathsOverlap` | controller/internal/system/mounts_linux.go | `(path) bool` | Mount checks | `_other.go` stubs return permissive values — Linux behavior is the real one |
|
||||||
| `system.CheckBackupDestination` | controller/internal/system/mounts_linux.go | `(path) DestinationHealth` | Tier2/offbox target vetting | Detects same-physical-device (`SamePhysicalDevice`) |
|
| `system.CheckBackupDestination` | controller/internal/system/mounts_linux.go | `(path) DestinationHealth` | Tier2/offbox target vetting | Detects same-physical-device (`SamePhysicalDevice`) |
|
||||||
|
| `backup.Manager.diskRelation` | controller/internal/backup/backup.go | `(a, b) (DiskRelation, reason)` — DiskDifferent / DiskSame / DiskUnknown | EVERY Tier-2 or one-copy "same disk?" decision (v0.317.0, felhom.eu `16-storage-physical-identity.md` §4) | Asks the host agent (`SetDiskJudge`, FeatureSameDisk ≥ agent 0.157.0); an older agent or an `unmapped` path → the old `sameDevice`/st_dev rule. Never call `sameDevice` directly for a Tier-2 decision — st_dev calls two partitions of one disk "different". Pinned by diskrelation_test.go |
|
||||||
| `system.ProbeStoragePath` | controller/internal/system/mounts_linux.go | `(path) ProbeResult` | Disconnect detection | — |
|
| `system.ProbeStoragePath` | controller/internal/system/mounts_linux.go | `(path) ProbeResult` | Disconnect detection | — |
|
||||||
| `appexport.DiskFree` | controller/internal/appexport/estimate.go | `(path) int64` | Free bytes for space gates (df-based, 0 on any error) | Exported v0.128.0 for the browser-upload gate; test seam = `web.uploadDiskFree` package var |
|
| `appexport.DiskFree` | controller/internal/appexport/estimate.go | `(path) int64` | Free bytes for space gates (df-based, 0 on any error) | Exported v0.128.0 for the browser-upload gate; test seam = `web.uploadDiskFree` package var |
|
||||||
| `stacks.ExportDataMounts` | controller/internal/stacks/delete.go | `(composePath, hddPath) []string` | THE .fab-export mount discovery (v0.130.0 C6B-F1) | Unions `${HDD_PATH}` binds + the `${USERDATA_PATH}` ROOT (single `userdata` entry — basename must round-trip the import's `<HDD_PATH>/<subdir>` mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side `stackAdapter` deliberately does NOT use it |
|
| `stacks.ExportDataMounts` | controller/internal/stacks/delete.go | `(composePath, hddPath) []string` | THE .fab-export mount discovery (v0.130.0 C6B-F1) | Unions `${HDD_PATH}` binds + the `${USERDATA_PATH}` ROOT (single `userdata` entry — basename must round-trip the import's `<HDD_PATH>/<subdir>` mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side `stackAdapter` deliberately does NOT use it |
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// v0.317.0 (`16` §4): the agent's same-disk verdict reaches the backup side. A seam built and never wired
|
||||||
|
// has shipped in this repo seven times; this walks main.go for the call AND for the agent call inside it.
|
||||||
|
// COMPANION RED-PROOF: delete the SetDiskJudge block → "not wired"; replace ac.SameDisk with a constant →
|
||||||
|
// "never asks the agent".
|
||||||
|
func TestDiskJudge_IsWiredAtStartup(t *testing.T) {
|
||||||
|
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var judge *ast.CallExpr
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
if c, ok := n.(*ast.CallExpr); ok {
|
||||||
|
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDiskJudge" {
|
||||||
|
judge = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if judge == nil {
|
||||||
|
t.Fatal("backupMgr.SetDiskJudge is not wired in main.go — Tier-2 would keep the st_dev rule for ever")
|
||||||
|
}
|
||||||
|
asks := false
|
||||||
|
ast.Inspect(judge, func(n ast.Node) bool {
|
||||||
|
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "SameDisk" {
|
||||||
|
asks = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !asks {
|
||||||
|
t.Fatal("SetDiskJudge is wired but never asks the agent (no SameDisk call inside it)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -583,6 +583,29 @@ func main() {
|
|||||||
// re-rendered or the restored shares exist on paper but are not exported. A seam rather than a
|
// re-rendered or the restored shares exist on paper but are not exported. A seam rather than a
|
||||||
// direct call — the backup package must not depend on the stacks package.
|
// direct call — the backup package must not depend on the stacks package.
|
||||||
backupMgr.SetSharesReconciler(stackMgr.ReconcileSamba)
|
backupMgr.SetSharesReconciler(stackMgr.ReconcileSamba)
|
||||||
|
// v0.317.0 (felhom.eu architecture/16-storage-physical-identity.md §4): every Tier-2 and one-copy
|
||||||
|
// decision asks the HOST AGENT whether two paths share a physical disk. An agent older than 0.157.0
|
||||||
|
// (FeatureSameDisk) or a failed call → supported=false → the backup side keeps the old st_dev rule,
|
||||||
|
// logged once. Pinned by TestDiskJudge_IsWiredAtStartup.
|
||||||
|
if cfg.LocalAPI.Endpoint != "" && cfg.LocalAPI.Token != "" {
|
||||||
|
if ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint); err != nil {
|
||||||
|
logger.Printf("[WARN] [backup] same-disk: agent client init failed (%v) — the st_dev rule stays", err)
|
||||||
|
} else {
|
||||||
|
backupMgr.SetDiskJudge(func(a, b string) (backup.DiskVerdict, bool) {
|
||||||
|
jctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if ac.Supports(jctx, agentapi.FeatureSameDisk) == agentapi.SupportNo {
|
||||||
|
return backup.DiskVerdict{}, false
|
||||||
|
}
|
||||||
|
r, err := ac.SameDisk(jctx, a, b)
|
||||||
|
if err != nil {
|
||||||
|
logger.Printf("[WARN] [backup] same-disk: agent call failed for %s / %s: %v", a, b, err)
|
||||||
|
return backup.DiskVerdict{}, false
|
||||||
|
}
|
||||||
|
return backup.DiskVerdict{Different: r.Different, Proven: r.Proven, Reason: r.Reason}, true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Slice 4: the guarded update's backup side ---
|
// --- Slice 4: the guarded update's backup side ---
|
||||||
|
|||||||
@@ -355,6 +355,12 @@ type DiskInfo struct {
|
|||||||
// agent predates the field — the disk-health card + 6h degradation check feature-detect on this and
|
// agent predates the field — the disk-health card + 6h degradation check feature-detect on this and
|
||||||
// render "Nincs adat" (never alarm) when nil. See DiskVerdictFor.
|
// render "Nincs adat" (never alarm) when nil. See DiskVerdictFor.
|
||||||
Smart *SmartSummary `json:"smart,omitempty"`
|
Smart *SmartSummary `json:"smart,omitempty"`
|
||||||
|
// Physical-disk identity (agent v0.157.0, `16` §3): kind, the keys of the physical disks under the place,
|
||||||
|
// proven|unknown + reason. Empty from an older agent or for an absent drive.
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
PhysicalDisks []string `json:"physical_disks,omitempty"`
|
||||||
|
Identity string `json:"identity,omitempty"`
|
||||||
|
IdentityReason string `json:"identity_reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity
|
// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity
|
||||||
@@ -419,6 +425,29 @@ var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup de
|
|||||||
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
|
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
|
||||||
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
|
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
|
||||||
|
|
||||||
|
// SameDiskResult is the agent's answer to POST /disks/same-disk (v0.157.0). Act on Proven first:
|
||||||
|
// Proven+Different = a counted second copy; Proven+!Different = the same disk; !Proven = unknown
|
||||||
|
// (Reason vm|network|usb_bridge|weak_wwn|no_serial|unmapped).
|
||||||
|
type SameDiskResult struct {
|
||||||
|
Different bool `json:"different"`
|
||||||
|
Proven bool `json:"proven"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameDisk asks the agent whether two guest paths sit on the same physical disk. Gate it with
|
||||||
|
// Supports(FeatureSameDisk) — an older agent answers 404.
|
||||||
|
func (c *Client) SameDisk(ctx context.Context, a, b string) (SameDiskResult, error) {
|
||||||
|
var out SameDiskResult
|
||||||
|
raw, err := c.post(ctx, "/disks/same-disk", map[string]string{"a": a, "b": b})
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return out, fmt.Errorf("agentapi: decode /disks/same-disk: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
|
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
|
||||||
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
|
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
|
||||||
var out DisksResponse
|
var out DisksResponse
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ const FeatureRetainedRecoveryClass Feature = "retained_recovery_class"
|
|||||||
// Unknown it stays, and an old agent's 404 is answered with the update sentence.
|
// Unknown it stays, and an old agent's 404 is answered with the update sentence.
|
||||||
const FeatureHostPowerOff Feature = "host_poweroff"
|
const FeatureHostPowerOff Feature = "host_poweroff"
|
||||||
|
|
||||||
|
// FeatureSameDisk — POST /disks/same-disk (agent v0.157.0, felhom.eu architecture/16-storage-physical-identity.md
|
||||||
|
// §4): the agent's physical-disk verdict for two guest paths. Below it the Tier-2 runner keeps the old st_dev
|
||||||
|
// rule (backup.Manager.diskRelation), logged once.
|
||||||
|
const FeatureSameDisk Feature = "same_disk"
|
||||||
|
|
||||||
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
|
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
|
||||||
// refuses — the existing agent-error paths speak honestly when the agent is down).
|
// refuses — the existing agent-error paths speak honestly when the agent is down).
|
||||||
type SupportState int
|
type SupportState int
|
||||||
@@ -168,6 +173,9 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
|
|||||||
FeatureHostPowerOff: func(ctx context.Context, p SupportProber) error {
|
FeatureHostPowerOff: func(ctx context.Context, p SupportProber) error {
|
||||||
return errNoRecoveryProbe
|
return errNoRecoveryProbe
|
||||||
},
|
},
|
||||||
|
FeatureSameDisk: func(ctx context.Context, p SupportProber) error {
|
||||||
|
return errNoRecoveryProbe // not probeable: decided by the version header alone
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
|
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
|
||||||
@@ -204,6 +212,8 @@ var featureMinAgent = map[Feature]string{
|
|||||||
|
|
||||||
// R-998 — POST /host/poweroff, the household's „A gép kikapcsolása".
|
// R-998 — POST /host/poweroff, the household's „A gép kikapcsolása".
|
||||||
FeatureHostPowerOff: "0.155.0",
|
FeatureHostPowerOff: "0.155.0",
|
||||||
|
|
||||||
|
FeatureSameDisk: "0.157.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
|
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// D4 (`16` §5): the no-drive refusal is a 400 the household can act on — not a 500.
|
||||||
|
func TestD4_NeedsDriveIs400(t *testing.T) {
|
||||||
|
err := util.MsgErrorf(stacks.ErrNeedsDrive, "err.stacks.needs_drive")
|
||||||
|
if got := deployStatusFor(err); got != http.StatusBadRequest {
|
||||||
|
t.Fatalf("ErrNeedsDrive → %d, want 400", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -437,7 +437,7 @@ func deployStatusFor(err error) int {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, stacks.ErrAlreadyDeployed), errors.Is(err, stacks.ErrKeptDataChoice):
|
case errors.Is(err, stacks.ErrAlreadyDeployed), errors.Is(err, stacks.ErrKeptDataChoice):
|
||||||
return http.StatusConflict
|
return http.StatusConflict
|
||||||
case errors.Is(err, stacks.ErrRequiredField), errors.Is(err, stacks.ErrPathMissing),
|
case errors.Is(err, stacks.ErrRequiredField), errors.Is(err, stacks.ErrPathMissing), errors.Is(err, stacks.ErrNeedsDrive),
|
||||||
errors.Is(err, stacks.ErrNotEnoughMemory):
|
errors.Is(err, stacks.ErrNotEnoughMemory):
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
}
|
}
|
||||||
@@ -535,7 +535,12 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.Printf("[ERROR] [api] Deploy failed for %s: %v", name, err)
|
r.logger.Printf("[ERROR] [api] Deploy failed for %s: %v", name, err)
|
||||||
// R-553 — the status code comes from the refusal's KIND (deployStatusFor), never from its words.
|
// R-553 — the status code comes from the refusal's KIND (deployStatusFor), never from its words.
|
||||||
writeJSON(w, deployStatusFor(err), apiResponse{OK: false, Error: r.errText(req, err)})
|
resp := apiResponse{OK: false, Error: r.errText(req, err)}
|
||||||
|
if errors.Is(err, stacks.ErrNeedsDrive) {
|
||||||
|
// D4 (`16` §5): the wizard renders the sentence with a link to the drives page.
|
||||||
|
resp.Data = map[string]string{"code": "needs_drive", "link": "/storage"}
|
||||||
|
}
|
||||||
|
writeJSON(w, deployStatusFor(err), resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,11 @@ type Manager struct {
|
|||||||
// and Tier-2 correctly refuses them — which makes the off-drive tests unrunnable rather than wrong.
|
// and Tier-2 correctly refuses them — which makes the off-drive tests unrunnable rather than wrong.
|
||||||
// Nil → the real system.SamePhysicalDevice (production always takes this path).
|
// Nil → the real system.SamePhysicalDevice (production always takes this path).
|
||||||
samePhysicalDevice func(a, b string) bool
|
samePhysicalDevice func(a, b string) bool
|
||||||
|
// diskJudge (v0.317.0, felhom.eu architecture/16-storage-physical-identity.md §4) asks the HOST AGENT
|
||||||
|
// whether two guest paths share a physical disk. supported=false (agent older than 0.157.0, or the
|
||||||
|
// call failed) → diskRelation keeps the old st_dev rule, logged once. nil → the old rule (tests).
|
||||||
|
diskJudge func(a, b string) (v DiskVerdict, supported bool)
|
||||||
|
diskLegacyOnce sync.Once
|
||||||
|
|
||||||
// migrationRunning, if set, reports whether a data migration is in progress. The scheduled
|
// migrationRunning, if set, reports whether a data migration is in progress. The scheduled
|
||||||
// backup paths skip when it returns true (Change 3 — backup ↔ migration mutual exclusion), so a
|
// backup paths skip when it returns true (Change 3 — backup ↔ migration mutual exclusion), so a
|
||||||
@@ -356,10 +361,6 @@ type FullBackupStatus struct {
|
|||||||
// backup used to render with a blank drive label). Matches the tier-2 UI wording.
|
// backup used to render with a blank drive label). Matches the tier-2 UI wording.
|
||||||
const systemDriveLabel = "Belső SSD (rendszer)"
|
const systemDriveLabel = "Belső SSD (rendszer)"
|
||||||
|
|
||||||
// singleCopyNotice is the honest single-drive signal (F6): shown when no off-drive tier-2 target
|
|
||||||
// exists, instead of silently implying a 3-2-1 guarantee the box cannot provide.
|
|
||||||
const singleCopyNotice = "Csak egy másolat készül (nincs második meghajtó) — a 3-2-1 mentéshez csatlakoztasson egy második meghajtót vagy offsite tárolót."
|
|
||||||
|
|
||||||
// DBDumpStatus holds the last DB dump result.
|
// DBDumpStatus holds the last DB dump result.
|
||||||
type DBDumpStatus struct {
|
type DBDumpStatus struct {
|
||||||
LastRun time.Time
|
LastRun time.Time
|
||||||
@@ -1363,6 +1364,59 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
|
|||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiskVerdict is the agent's answer to "do these two paths share a physical disk?" (`16` §4).
|
||||||
|
type DiskVerdict struct {
|
||||||
|
Different bool
|
||||||
|
Proven bool
|
||||||
|
Reason string // "" | same_disk | vm | network | usb_bridge | weak_wwn | no_serial | unmapped
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiskRelation is what a Tier-2 decision acts on.
|
||||||
|
type DiskRelation int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DiskDifferent — a different physical disk, proven by the agent (or the old st_dev rule said so):
|
||||||
|
// the copy is made and COUNTS as a second copy.
|
||||||
|
DiskDifferent DiskRelation = iota
|
||||||
|
// DiskSame — the same physical disk: no second copy is made (`16` D1).
|
||||||
|
DiskSame
|
||||||
|
// DiskUnknown — the agent cannot prove it (a VM, a USB bridge that hides the serial): the copy is made
|
||||||
|
// but does NOT count, and the page says why (`16` D2).
|
||||||
|
DiskUnknown
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetDiskJudge wires the agent's same-disk call (main: agentapi FeatureSameDisk).
|
||||||
|
func (m *Manager) SetDiskJudge(fn func(a, b string) (DiskVerdict, bool)) { m.diskJudge = fn }
|
||||||
|
|
||||||
|
// diskRelation is the ONE place a Tier-2 or one-copy decision asks "same disk?" (`16` §4). The agent's
|
||||||
|
// verdict wins when it can give one; "unmapped" (a path the agent did not make, e.g. an absent drive) and
|
||||||
|
// an older agent fall back to today's st_dev rule — never a silent change, never a crash. Pinned by
|
||||||
|
// diskrelation_test.go.
|
||||||
|
func (m *Manager) diskRelation(a, b string) (DiskRelation, string) {
|
||||||
|
if m.diskJudge != nil {
|
||||||
|
v, ok := m.diskJudge(a, b)
|
||||||
|
if ok && v.Reason != "unmapped" {
|
||||||
|
switch {
|
||||||
|
case v.Proven && v.Different:
|
||||||
|
return DiskDifferent, ""
|
||||||
|
case v.Proven:
|
||||||
|
return DiskSame, v.Reason
|
||||||
|
default:
|
||||||
|
return DiskUnknown, v.Reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
m.diskLegacyOnce.Do(func() {
|
||||||
|
m.logger.Printf("[INFO] [backup] same-disk: the host agent cannot answer (older than 0.157.0, or the call failed) — using the filesystem-device rule")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.sameDevice(a, b) {
|
||||||
|
return DiskSame, "st_dev"
|
||||||
|
}
|
||||||
|
return DiskDifferent, "st_dev"
|
||||||
|
}
|
||||||
|
|
||||||
// sameDevice reports whether two paths sit on the same physical device, through the test seam when
|
// sameDevice reports whether two paths sit on the same physical device, through the test seam when
|
||||||
// one is installed. Nil seam → system.SamePhysicalDevice, i.e. byte-for-byte the previous behaviour.
|
// one is installed. Nil seam → system.SamePhysicalDevice, i.e. byte-for-byte the previous behaviour.
|
||||||
func (m *Manager) sameDevice(a, b string) bool {
|
func (m *Manager) sameDevice(a, b string) bool {
|
||||||
@@ -1384,29 +1438,47 @@ func (m *Manager) sameDevice(a, b string) bool {
|
|||||||
return system.SamePhysicalDevice(a, b)
|
return system.SamePhysicalDevice(a, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasOffDriveTarget reports whether any registered, schedulable storage path lives on a physical disk
|
// offDriveCopyState reports whether any registered, schedulable, NON-network storage path is on a physical
|
||||||
// OTHER than the system drive — i.e. whether a genuine off-drive (tier-2) copy is possible at all.
|
// disk OTHER than the system drive — counted only when proven (`16` §4). unknown is true when such a path
|
||||||
// When false the box is single-drive: tier-1 is the ONLY local copy and 3-2-1 needs a 2nd drive or
|
// exists but the agent cannot prove it is another disk (a VM, a hidden USB serial). A NAS never counts
|
||||||
// offsite (F6 — surfaced honestly via SingleCopyWarning, never a faked guarantee).
|
// (R-1075: it is never a Tier-2 target, so it must not hide the one-copy notice).
|
||||||
func (m *Manager) hasOffDriveTarget() bool {
|
func (m *Manager) offDriveCopyState() (counted, unknown bool) {
|
||||||
if m.settings == nil || m.systemDataPath == "" {
|
if m.settings == nil || m.systemDataPath == "" {
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||||
if sp.Path == m.systemDataPath || m.sameDevice(m.systemDataPath, sp.Path) {
|
if sp.Path == m.systemDataPath || sp.IsNetwork() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return true
|
switch rel, _ := m.diskRelation(m.systemDataPath, sp.Path); rel {
|
||||||
|
case DiskDifferent:
|
||||||
|
return true, false
|
||||||
|
case DiskUnknown:
|
||||||
|
unknown = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false, unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
// singleCopyWarning returns the honest single-drive notice, or "" when an off-drive target exists.
|
// hasOffDriveTarget reports whether a COUNTED off-drive (tier-2) copy is possible at all.
|
||||||
|
func (m *Manager) hasOffDriveTarget() bool {
|
||||||
|
counted, _ := m.offDriveCopyState()
|
||||||
|
return counted
|
||||||
|
}
|
||||||
|
|
||||||
|
// singleCopyWarning returns the honest notice (`16` §4), or "" when a counted off-drive target exists:
|
||||||
|
// "only one copy" when there is no second disk (or it is the same disk); "cannot check" when a second
|
||||||
|
// drive exists whose disk the agent cannot prove. Both sentences are i18n (informal voice — R-1072).
|
||||||
func (m *Manager) singleCopyWarning() string {
|
func (m *Manager) singleCopyWarning() string {
|
||||||
if m.hasOffDriveTarget() {
|
counted, unknown := m.offDriveCopyState()
|
||||||
|
switch {
|
||||||
|
case counted:
|
||||||
return ""
|
return ""
|
||||||
|
case unknown:
|
||||||
|
return m.note("note.backup.single_copy_unproven")
|
||||||
|
default:
|
||||||
|
return m.note("note.backup.single_copy")
|
||||||
}
|
}
|
||||||
return singleCopyNotice
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sysDriveLabelFor returns the drive label for a stack's tier-1 restore point — the clear
|
// sysDriveLabelFor returns the drive label for a stack's tier-1 restore point — the clear
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// felhom.eu architecture/16-storage-physical-identity.md §4 — the agent's verdict decides Tier-2 and the
|
||||||
|
// one-copy notice. Each test asserts the CONSEQUENCE (is a target picked, does it count, what does the page
|
||||||
|
// say), not the mechanism.
|
||||||
|
|
||||||
|
// judgeBy answers per target path; paths not in the map are "unmapped" (→ the old st_dev rule).
|
||||||
|
func judgeBy(m map[string]DiskVerdict) func(a, b string) (DiskVerdict, bool) {
|
||||||
|
return func(a, b string) (DiskVerdict, bool) {
|
||||||
|
if v, ok := m[b]; ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
return DiskVerdict{Reason: "unmapped"}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
provenDifferent = DiskVerdict{Different: true, Proven: true}
|
||||||
|
provenSame = DiskVerdict{Different: false, Proven: true, Reason: "same_disk"}
|
||||||
|
unknownVM = DiskVerdict{Different: true, Proven: false, Reason: "vm"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func addDrive(t *testing.T, sett *settings.Settings, sp settings.StoragePath) {
|
||||||
|
t.Helper()
|
||||||
|
sp.Schedulable = true
|
||||||
|
if err := sett.AddStoragePath(sp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 1: different + proven → made, counted, notice hidden.
|
||||||
|
func TestDisk16_ProvenDifferent_CountedNoNotice(t *testing.T) {
|
||||||
|
m, sett := newTestManager(t, "/srv/sys")
|
||||||
|
addDrive(t, sett, settings.StoragePath{Path: "/mnt/b", Label: "B"})
|
||||||
|
m.SetDiskJudge(judgeBy(map[string]DiskVerdict{"/mnt/b": provenDifferent}))
|
||||||
|
tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1)
|
||||||
|
if err != nil || tg.Unproven || tg.NamespaceRoot != "/mnt/b" {
|
||||||
|
t.Fatalf("want /mnt/b counted, got %+v err=%v", tg, err)
|
||||||
|
}
|
||||||
|
if w := m.singleCopyWarning(); w != "" {
|
||||||
|
t.Fatalf("a proven second disk must hide the notice, got %q", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 2: same disk (two partitions) → NOT made (D1), notice shown — even though st_dev would say "different".
|
||||||
|
func TestDisk16_SameDisk_NotMadeAndNoticeShown(t *testing.T) {
|
||||||
|
m, sett := newTestManager(t, "/srv/sys")
|
||||||
|
addDrive(t, sett, settings.StoragePath{Path: "/mnt/part2", Label: "P2"})
|
||||||
|
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { return provenSame, true })
|
||||||
|
if tg, err := m.selectTier2TargetFrom("app", "/mnt/part1", 1, 1); err == nil {
|
||||||
|
t.Fatalf("a same-disk partition must not be a Tier-2 target, got %+v", tg)
|
||||||
|
}
|
||||||
|
if w := m.singleCopyWarning(); w != m.note("note.backup.single_copy") {
|
||||||
|
t.Fatalf("same disk: want the one-copy notice, got %q", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 3: unknown (a VM) → made, NOT counted (D2), notice shown with the "cannot check" sentence.
|
||||||
|
func TestDisk16_Unknown_MadeNotCountedCannotCheck(t *testing.T) {
|
||||||
|
m, sett := newTestManager(t, "/srv/sys")
|
||||||
|
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/media_a", Label: "A"})
|
||||||
|
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { return unknownVM, true })
|
||||||
|
tg, err := m.selectTier2TargetFrom("app", "/mnt/felhom-drives/adatlemez", 1, 1)
|
||||||
|
if err != nil || !tg.Unproven || tg.UnprovenReason != "vm" {
|
||||||
|
t.Fatalf("unknown: want the copy made and marked unproven, got %+v err=%v", tg, err)
|
||||||
|
}
|
||||||
|
w := m.singleCopyWarning()
|
||||||
|
if w != m.note("note.backup.single_copy_unproven") || !strings.Contains(w, "nem tudja ellenőrizni") {
|
||||||
|
t.Fatalf("unknown: want the cannot-check notice, got %q", w)
|
||||||
|
}
|
||||||
|
// A proven-different drive beats an unknown one.
|
||||||
|
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/real", Label: "R"})
|
||||||
|
m.SetDiskJudge(judgeBy(map[string]DiskVerdict{"/mnt/felhom-drives/media_a": unknownVM, "/mnt/felhom-drives/real": provenDifferent}))
|
||||||
|
if tg, _ := m.selectTier2TargetFrom("app", "/mnt/felhom-drives/adatlemez", 1, 1); tg == nil || tg.NamespaceRoot != "/mnt/felhom-drives/real" || tg.Unproven {
|
||||||
|
t.Fatalf("a proven drive must win over an unknown one, got %+v", tg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 4: a network share → never a Tier-2 target and never hides the notice (R-1075).
|
||||||
|
func TestDisk16_NetworkShare_NeverHidesTheNotice(t *testing.T) {
|
||||||
|
m, sett := newTestManager(t, "/srv/sys")
|
||||||
|
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/nas", Label: "NAS", Kind: settings.StorageKindNetwork})
|
||||||
|
// No judge at all — the OLD st_dev rule calls the NAS "different", which is exactly R-1075.
|
||||||
|
if m.hasOffDriveTarget() {
|
||||||
|
t.Fatal("R-1075: a NAS made the box claim an off-drive copy")
|
||||||
|
}
|
||||||
|
if w := m.singleCopyWarning(); w != m.note("note.backup.single_copy") {
|
||||||
|
t.Fatalf("a NAS must not hide the one-copy notice, got %q", w)
|
||||||
|
}
|
||||||
|
if tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1); err == nil && !tg.IsSystemDrive {
|
||||||
|
t.Fatalf("a NAS must never be a Tier-2 target, got %+v", tg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An older agent (supported=false) keeps today's behaviour exactly: the st_dev seam decides.
|
||||||
|
func TestDisk16_OldAgent_KeepsTheStDevRule(t *testing.T) {
|
||||||
|
m, sett := newTestManager(t, "/srv/sys")
|
||||||
|
addDrive(t, sett, settings.StoragePath{Path: "/mnt/b", Label: "B"})
|
||||||
|
calls := 0
|
||||||
|
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { calls++; return DiskVerdict{}, false })
|
||||||
|
tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1)
|
||||||
|
if err != nil || tg.NamespaceRoot != "/mnt/b" || tg.Unproven {
|
||||||
|
t.Fatalf("old agent: want today's pick (/mnt/b, counted), got %+v err=%v", tg, err)
|
||||||
|
}
|
||||||
|
if calls == 0 {
|
||||||
|
t.Fatal("the judge was never asked")
|
||||||
|
}
|
||||||
|
if m.singleCopyWarning() != "" {
|
||||||
|
t.Fatal("old agent: a second drive by st_dev must still hide the notice (today's behaviour)")
|
||||||
|
}
|
||||||
|
// And the st_dev rule's own same-disk answer still stands.
|
||||||
|
m.samePhysicalDevice = func(a, b string) bool { return true }
|
||||||
|
if _, err := m.selectTier2TargetFrom("app", "/srv/x", 1, 1); err == nil {
|
||||||
|
t.Fatal("old agent: st_dev 'same' must still refuse the target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The success record carries the mark the app row renders.
|
||||||
|
func TestDisk16_UnprovenCopyIsRecorded(t *testing.T) {
|
||||||
|
m, sett := newTestManager(t, "/srv/sys")
|
||||||
|
m.recordTier2Success("app", &Tier2Target{NamespaceRoot: "/mnt/a", Unproven: true, UnprovenReason: "vm"}, 1, "", 0)
|
||||||
|
cd := sett.GetCrossDriveConfig("app")
|
||||||
|
if cd == nil || !cd.Unproven || cd.UnprovenReason != "vm" {
|
||||||
|
t.Fatalf("want the unproven mark recorded, got %+v", cd)
|
||||||
|
}
|
||||||
|
m.recordTier2Success("app", &Tier2Target{NamespaceRoot: "/mnt/a"}, 1, "", 0)
|
||||||
|
if cd := sett.GetCrossDriveConfig("app"); cd.Unproven {
|
||||||
|
t.Fatal("a later proven copy must clear the mark")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ func TestRunAllTier2_IncludesVolumeOnlyApps(t *testing.T) {
|
|||||||
func TestSingleCopyWarning_HonestOnSingleDrive(t *testing.T) {
|
func TestSingleCopyWarning_HonestOnSingleDrive(t *testing.T) {
|
||||||
// Single-drive: only the system drive, no schedulable off-drive paths.
|
// Single-drive: only the system drive, no schedulable off-drive paths.
|
||||||
m1, _ := newTestManager(t, "/srv/sys")
|
m1, _ := newTestManager(t, "/srv/sys")
|
||||||
if got := m1.singleCopyWarning(); got != singleCopyNotice {
|
if got := m1.singleCopyWarning(); got != m1.note("note.backup.single_copy") {
|
||||||
t.Errorf("single-drive box must surface the honest notice, got %q", got)
|
t.Errorf("single-drive box must surface the honest notice, got %q", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ type Tier2Target struct {
|
|||||||
IsSystemDrive bool // target is the internal SSD/system drive (DB/config only)
|
IsSystemDrive bool // target is the internal SSD/system drive (DB/config only)
|
||||||
StateOnly bool // §2.2: this target carries unit + MANDATORY only (optional legs skipped) — the SSD tier
|
StateOnly bool // §2.2: this target carries unit + MANDATORY only (optional legs skipped) — the SSD tier
|
||||||
Reason string // why this target (Hungarian, for UI/logs)
|
Reason string // why this target (Hungarian, for UI/logs)
|
||||||
|
// Unproven (`16` D2): the agent cannot prove this target is another physical disk (a VM, a USB bridge
|
||||||
|
// that hides the serial). The copy is still made; it does not count as a second copy, and the app's
|
||||||
|
// row says why. UnprovenReason is the agent's reason code.
|
||||||
|
Unproven bool
|
||||||
|
UnprovenReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// tier2FitsHeadroom reports whether a unit of unitGB fits on a system/rootfs drive while leaving a
|
// tier2FitsHeadroom reports whether a unit of unitGB fits on a system/rootfs drive while leaving a
|
||||||
@@ -116,47 +121,75 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
|
|||||||
sawNetworkCandidate = true
|
sawNetworkCandidate = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if sp.Path == sourceDrive || m.sameDevice(sourceDrive, sp.Path) {
|
if sp.Path == sourceDrive {
|
||||||
break // pinned target is on the same physical disk — not off-drive; fall through
|
break
|
||||||
|
}
|
||||||
|
rel, why := m.diskRelation(sourceDrive, sp.Path)
|
||||||
|
if rel == DiskSame {
|
||||||
|
break // pinned target is on the same physical disk — not off-drive; fall through (`16` D1)
|
||||||
}
|
}
|
||||||
label := sp.Label
|
label := sp.Label
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = filepath.Base(sp.Path)
|
label = filepath.Base(sp.Path)
|
||||||
}
|
}
|
||||||
return &Tier2Target{
|
return &Tier2Target{
|
||||||
NamespaceRoot: NamespaceRoot(sp.Path, true),
|
NamespaceRoot: NamespaceRoot(sp.Path, true),
|
||||||
Label: label,
|
Label: label,
|
||||||
Reason: m.note("note.tier2.reason_manual"),
|
Reason: m.note("note.tier2.reason_manual"),
|
||||||
|
Unproven: rel == DiskUnknown,
|
||||||
|
UnprovenReason: unprovenWhy(rel, why),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Prefer another registered user-data drive on a DIFFERENT physical disk, NON-network.
|
// 1. Prefer another registered user-data drive on a DIFFERENT physical disk, NON-network. A drive the
|
||||||
|
// agent PROVES is another disk wins; failing that, the first drive it cannot judge (`16` D2: the copy
|
||||||
|
// is made, not counted). A drive on the SAME disk is never a target (`16` D1).
|
||||||
if m.settings != nil {
|
if m.settings != nil {
|
||||||
|
var firstUnknown *Tier2Target
|
||||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||||
if sp.Path == sourceDrive || m.sameDevice(sourceDrive, sp.Path) {
|
if sp.Path == sourceDrive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if sp.IsNetwork() {
|
if sp.IsNetwork() {
|
||||||
sawNetworkCandidate = true // F-6C-1: never a tier-2 target
|
sawNetworkCandidate = true // F-6C-1: never a tier-2 target
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
rel, why := m.diskRelation(sourceDrive, sp.Path)
|
||||||
|
if rel == DiskSame {
|
||||||
|
continue
|
||||||
|
}
|
||||||
label := sp.Label
|
label := sp.Label
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = filepath.Base(sp.Path)
|
label = filepath.Base(sp.Path)
|
||||||
}
|
}
|
||||||
return &Tier2Target{
|
t := &Tier2Target{
|
||||||
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
|
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
|
||||||
Label: label,
|
Label: label,
|
||||||
Reason: m.note("note.tier2.reason_other_drive"),
|
Reason: m.note("note.tier2.reason_other_drive"),
|
||||||
}, nil
|
Unproven: rel == DiskUnknown,
|
||||||
|
UnprovenReason: unprovenWhy(rel, why),
|
||||||
|
}
|
||||||
|
if rel == DiskDifferent {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
if firstUnknown == nil {
|
||||||
|
firstUnknown = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstUnknown != nil {
|
||||||
|
return firstUnknown, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fall back to the internal SSD (system data path) — STATE-ONLY set only.
|
// 2. Fall back to the internal SSD (system data path) — STATE-ONLY set only.
|
||||||
sys := m.systemDataPath
|
sys := m.systemDataPath
|
||||||
if sys == "" || m.sameDevice(sourceDrive, sys) {
|
sysRel, sysWhy := DiskSame, ""
|
||||||
|
if sys != "" {
|
||||||
|
sysRel, sysWhy = m.diskRelation(sourceDrive, sys)
|
||||||
|
}
|
||||||
|
if sys == "" || sysRel == DiskSame {
|
||||||
if sawNetworkCandidate {
|
if sawNetworkCandidate {
|
||||||
return nil, errTier2NetworkOnly // the only off-disk candidate was a NAS
|
return nil, errTier2NetworkOnly // the only off-disk candidate was a NAS
|
||||||
}
|
}
|
||||||
@@ -175,9 +208,20 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
|
|||||||
IsSystemDrive: true,
|
IsSystemDrive: true,
|
||||||
StateOnly: true,
|
StateOnly: true,
|
||||||
Reason: m.note("note.tier2.reason_no_second"),
|
Reason: m.note("note.tier2.reason_no_second"),
|
||||||
|
// `16` D2: an app on a drive the agent cannot judge against the system disk — made, not counted.
|
||||||
|
Unproven: sysRel == DiskUnknown,
|
||||||
|
UnprovenReason: unprovenWhy(sysRel, sysWhy),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unprovenWhy keeps the agent's reason only for an unknown verdict.
|
||||||
|
func unprovenWhy(rel DiskRelation, why string) string {
|
||||||
|
if rel == DiskUnknown {
|
||||||
|
return why
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// tier2FitsSystemDrive checks the size-aware rootfs-headroom guard for the SSD target.
|
// tier2FitsSystemDrive checks the size-aware rootfs-headroom guard for the SSD target.
|
||||||
func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool {
|
func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool {
|
||||||
di := system.GetDiskUsage(sys)
|
di := system.GetDiskUsage(sys)
|
||||||
@@ -326,8 +370,9 @@ func (m *Manager) RunTier2(stackName string) error {
|
|||||||
m.logger.Printf("[INFO] [backup] Tier 2 for %s: no off-drive target — %s", stackName, reason)
|
m.logger.Printf("[INFO] [backup] Tier 2 for %s: no off-drive target — %s", stackName, reason)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Defense-in-depth off-drive guard (selection already enforced it).
|
// Defense-in-depth off-drive guard (selection already enforced it) — asked again right before the run,
|
||||||
if m.sameDevice(sourceDrive, target.NamespaceRoot) {
|
// so a disk swapped in the same bay is re-judged (`16` §3 "before each Tier-2 run").
|
||||||
|
if rel, _ := m.diskRelation(sourceDrive, target.NamespaceRoot); rel == DiskSame {
|
||||||
m.recordTier2NoTarget(stackName, m.note("note.tier2.same_disk"))
|
m.recordTier2NoTarget(stackName, m.note("note.tier2.same_disk"))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -527,7 +572,10 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info {
|
|||||||
}
|
}
|
||||||
// Eligible alternative drives: registered, schedulable, on a DIFFERENT physical disk.
|
// Eligible alternative drives: registered, schedulable, on a DIFFERENT physical disk.
|
||||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||||
if sp.Path == source || m.sameDevice(source, sp.Path) {
|
if sp.Path == source {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rel, _ := m.diskRelation(source, sp.Path); rel == DiskSame {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
label := sp.Label
|
label := sp.Label
|
||||||
@@ -636,6 +684,9 @@ func (m *Manager) recordTier2SuccessWithUnit(stackName string, target *Tier2Targ
|
|||||||
c.LastDuration = dur.Round(time.Second).String()
|
c.LastDuration = dur.Round(time.Second).String()
|
||||||
c.LastSizeHuman = humanizeBytes(sizeBytes)
|
c.LastSizeHuman = humanizeBytes(sizeBytes)
|
||||||
c.LastError = "" // a success invalidates the previous error
|
c.LastError = "" // a success invalidates the previous error
|
||||||
|
// `16` D2: a copy on a disk the agent cannot prove is another one — kept, not counted.
|
||||||
|
c.Unproven = target.Unproven
|
||||||
|
c.UnprovenReason = target.UnprovenReason
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ func (m *Manager) RunSharesTier2() error {
|
|||||||
}
|
}
|
||||||
// Defense-in-depth off-drive guard (selection already enforced it): a leg's target may never
|
// Defense-in-depth off-drive guard (selection already enforced it): a leg's target may never
|
||||||
// be that leg's own source drive — that would be a same-disk "copy" pretending to be tier 2.
|
// be that leg's own source drive — that would be a same-disk "copy" pretending to be tier 2.
|
||||||
if m.sameDevice(g.sourceDrive, target.NamespaceRoot) {
|
if rel, _ := m.diskRelation(g.sourceDrive, target.NamespaceRoot); rel == DiskSame {
|
||||||
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: a kiválasztott cél ugyanazon a fizikai lemezen van", g.sourceDrive))
|
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: a kiválasztott cél ugyanazon a fizikai lemezen van", g.sourceDrive))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"backup.tier2.badge_running": "Running...",
|
"backup.tier2.badge_running": "Running...",
|
||||||
"backup.tier2.dest_ssd": "internal SSD (database and settings only)",
|
"backup.tier2.dest_ssd": "internal SSD (database and settings only)",
|
||||||
"backup.tier2.no_drive": "No second drive",
|
"backup.tier2.no_drive": "No second drive",
|
||||||
|
"backup.tier2.unproven": "Felhom cannot check that this drive is really a different disk (inside a virtual machine and behind some USB enclosures this is not visible), so it does not count this copy as a second copy.",
|
||||||
"backup.tier2.schedule_daily": "Daily",
|
"backup.tier2.schedule_daily": "Daily",
|
||||||
"backups.a_kijeloles_nem_sikerult": "The drive could not be assigned:",
|
"backups.a_kijeloles_nem_sikerult": "The drive could not be assigned:",
|
||||||
"backups.a_meghajto_kijelolve": "The drive is assigned.",
|
"backups.a_meghajto_kijelolve": "The drive is assigned.",
|
||||||
@@ -1192,6 +1193,7 @@
|
|||||||
"deploy.telepites": "Install",
|
"deploy.telepites": "Install",
|
||||||
"deploy.telepites_folyamatban": "Installing...",
|
"deploy.telepites_folyamatban": "Installing...",
|
||||||
"deploy.telepites_inditasa": "Start install",
|
"deploy.telepites_inditasa": "Start install",
|
||||||
|
"deploy.meghajtok_megnyitasa": "Open Drives",
|
||||||
"deploy.telepites_sikeres": "Install succeeded!",
|
"deploy.telepites_sikeres": "Install succeeded!",
|
||||||
"deploy.telepites_sikertelen": "Install failed",
|
"deploy.telepites_sikertelen": "Install failed",
|
||||||
"deploy.telepites_utan_nem_modosithato": "cannot be changed after install",
|
"deploy.telepites_utan_nem_modosithato": "cannot be changed after install",
|
||||||
@@ -1359,6 +1361,7 @@
|
|||||||
"err.stacks.ellenorzes_sikertelen_appdata": "the check failed (%s app data): %s",
|
"err.stacks.ellenorzes_sikertelen_appdata": "the check failed (%s app data): %s",
|
||||||
"err.stacks.ellenorzes_sikertelen_mentes": "the check failed (%s backup): %s",
|
"err.stacks.ellenorzes_sikertelen_mentes": "the check failed (%s backup): %s",
|
||||||
"err.stacks.field_required": "the %q (%s) field has to be filled in",
|
"err.stacks.field_required": "the %q (%s) field has to be filled in",
|
||||||
|
"err.stacks.needs_drive": "This app needs a storage drive. You can add one on the Storage → Drives page.",
|
||||||
"err.stacks.field_required_password": "the %q field has to be filled in — use the Generate button, or type a password",
|
"err.stacks.field_required_password": "the %q field has to be filled in — use the Generate button, or type a password",
|
||||||
"err.stacks.forras_nevter_olvasasa_sikertelen": "reading the source folder failed: %s",
|
"err.stacks.forras_nevter_olvasasa_sikertelen": "reading the source folder failed: %s",
|
||||||
"err.stacks.forras_torlese_sikertelen": "removing the source failed (%s): %s",
|
"err.stacks.forras_torlese_sikertelen": "removing the source failed (%s): %s",
|
||||||
@@ -1827,6 +1830,8 @@
|
|||||||
"note.tier2.reason_no_second": "no second data drive — only the database and settings fit on the internal SSD; large files need a second drive",
|
"note.tier2.reason_no_second": "no second data drive — only the database and settings fit on the internal SSD; large files need a second drive",
|
||||||
"note.tier2.reason_other_drive": "another data drive",
|
"note.tier2.reason_other_drive": "another data drive",
|
||||||
"note.tier2.same_disk": "the target you picked is on the same physical disk",
|
"note.tier2.same_disk": "the target you picked is on the same physical disk",
|
||||||
|
"note.backup.single_copy_unproven": "Felhom cannot check that the second drive is really a different disk (inside a virtual machine and behind some USB enclosures this is not visible), so it does not count it as a second copy.",
|
||||||
|
"note.backup.single_copy": "Only one local copy is made: there is no second disk, or it is on the same disk as the data. This protects against mistakes, not against a disk failure. A different disk or the off-site copy protects against a disk failure.",
|
||||||
"note.tier2.ssd_partial": "Only the settings, the database and the required data fit on the internal SSD — the optional content was not copied.",
|
"note.tier2.ssd_partial": "Only the settings, the database and the required data fit on the internal SSD — the optional content was not copied.",
|
||||||
"note.tier2.unit_preserved": "The data package on the main drive was incomplete, so the complete one already in the second copy was kept. The copy's data package is therefore older than this backup.",
|
"note.tier2.unit_preserved": "The data package on the main drive was incomplete, so the complete one already in the second copy was kept. The copy's data package is therefore older than this backup.",
|
||||||
"note.tier2_no_coverage": "This app's data cannot be restored from this copy — the app was not stopped. Use the Start restore button on the Backup → Restore page.",
|
"note.tier2_no_coverage": "This app's data cannot be restored from this copy — the app was not stopped. Use the Start restore button on the Backup → Restore page.",
|
||||||
|
|||||||
@@ -201,6 +201,7 @@
|
|||||||
"backup.tier2.badge_running": "Fut...",
|
"backup.tier2.badge_running": "Fut...",
|
||||||
"backup.tier2.dest_ssd": "belső SSD (csak DB/konfiguráció)",
|
"backup.tier2.dest_ssd": "belső SSD (csak DB/konfiguráció)",
|
||||||
"backup.tier2.no_drive": "Nincs 2. meghajtó",
|
"backup.tier2.no_drive": "Nincs 2. meghajtó",
|
||||||
|
"backup.tier2.unproven": "A Felhom nem tudja ellenőrizni, hogy ez a meghajtó valóban másik lemez-e (virtuális gépben és egyes USB-házakban ez nem látszik), ezért ezt a másolatot nem számolja második másolatnak.",
|
||||||
"backup.tier2.schedule_daily": "Naponta",
|
"backup.tier2.schedule_daily": "Naponta",
|
||||||
"backups.a_kijeloles_nem_sikerult": "A kijelölés nem sikerült:",
|
"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 meghajtó kijelölve.",
|
||||||
@@ -1187,6 +1188,7 @@
|
|||||||
"deploy.telepites": "Telepítés",
|
"deploy.telepites": "Telepítés",
|
||||||
"deploy.telepites_folyamatban": "Telepítés folyamatban...",
|
"deploy.telepites_folyamatban": "Telepítés folyamatban...",
|
||||||
"deploy.telepites_inditasa": "Telepítés indítása",
|
"deploy.telepites_inditasa": "Telepítés indítása",
|
||||||
|
"deploy.meghajtok_megnyitasa": "Meghajtók megnyitása",
|
||||||
"deploy.telepites_sikeres": "Telepítés sikeres!",
|
"deploy.telepites_sikeres": "Telepítés sikeres!",
|
||||||
"deploy.telepites_sikertelen": "Telepítés sikertelen",
|
"deploy.telepites_sikertelen": "Telepítés sikertelen",
|
||||||
"deploy.telepites_utan_nem_modosithato": "telepítés után nem módosítható",
|
"deploy.telepites_utan_nem_modosithato": "telepítés után nem módosítható",
|
||||||
@@ -1352,6 +1354,7 @@
|
|||||||
"err.stacks.ellenorzes_sikertelen_appdata": "ellenőrzés sikertelen (%s appdata): %s",
|
"err.stacks.ellenorzes_sikertelen_appdata": "ellenőrzés sikertelen (%s appdata): %s",
|
||||||
"err.stacks.ellenorzes_sikertelen_mentes": "ellenőrzés sikertelen (%s mentés): %s",
|
"err.stacks.ellenorzes_sikertelen_mentes": "ellenőrzés sikertelen (%s mentés): %s",
|
||||||
"err.stacks.field_required": "a(z) %q (%s) mező kitöltése kötelező",
|
"err.stacks.field_required": "a(z) %q (%s) mező kitöltése kötelező",
|
||||||
|
"err.stacks.needs_drive": "Ehhez az alkalmazáshoz egy adattároló kell. A Meghajtók oldalon adhatsz hozzá egyet.",
|
||||||
"err.stacks.field_required_password": "a(z) %q mező kitöltése kötelező — használd a Generálás gombot vagy írj be egy jelszót",
|
"err.stacks.field_required_password": "a(z) %q mező kitöltése kötelező — használd a Generálás gombot vagy írj be egy jelszót",
|
||||||
"err.stacks.forras_nevter_olvasasa_sikertelen": "forrás névtér olvasása sikertelen: %s",
|
"err.stacks.forras_nevter_olvasasa_sikertelen": "forrás névtér olvasása sikertelen: %s",
|
||||||
"err.stacks.forras_torlese_sikertelen": "forrás törlése sikertelen (%s): %s",
|
"err.stacks.forras_torlese_sikertelen": "forrás törlése sikertelen (%s): %s",
|
||||||
@@ -1815,6 +1818,8 @@
|
|||||||
"note.tier2.reason_no_second": "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell",
|
"note.tier2.reason_no_second": "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell",
|
||||||
"note.tier2.reason_other_drive": "másik adatmeghajtó",
|
"note.tier2.reason_other_drive": "másik adatmeghajtó",
|
||||||
"note.tier2.same_disk": "a kiválasztott cél ugyanazon a fizikai lemezen van",
|
"note.tier2.same_disk": "a kiválasztott cél ugyanazon a fizikai lemezen van",
|
||||||
|
"note.backup.single_copy_unproven": "A Felhom nem tudja ellenőrizni, hogy a második meghajtó valóban másik lemez-e (virtuális gépben és egyes USB-házakban ez nem látszik), ezért nem számolja második másolatnak.",
|
||||||
|
"note.backup.single_copy": "Csak egy helyi másolat készül: nincs második lemez, vagy ugyanazon a lemezen van, mint az adatok. Ez a véletlen törlés ellen véd, lemezhiba ellen nem. Lemezhiba ellen egy másik lemez vagy a távoli mentés véd.",
|
||||||
"note.tier2.ssd_partial": "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.",
|
"note.tier2.ssd_partial": "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.",
|
||||||
"note.tier2.unit_preserved": "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés.",
|
"note.tier2.unit_preserved": "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés.",
|
||||||
"note.tier2_no_coverage": "Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon.",
|
"note.tier2_no_coverage": "Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon.",
|
||||||
|
|||||||
@@ -550,6 +550,10 @@ type CrossDriveBackup struct {
|
|||||||
// destination drive (a registered storage Path) instead of the auto-pick ("" = auto).
|
// destination drive (a registered storage Path) instead of the auto-pick ("" = auto).
|
||||||
UserDisabled bool `json:"user_disabled,omitempty"`
|
UserDisabled bool `json:"user_disabled,omitempty"`
|
||||||
PreferredTarget string `json:"preferred_target,omitempty"`
|
PreferredTarget string `json:"preferred_target,omitempty"`
|
||||||
|
// Unproven (v0.317.0, `16` D2): the last copy sits on a drive the host agent cannot prove is another
|
||||||
|
// physical disk (a VM, a hidden USB serial). It was made; it does not count as a second copy.
|
||||||
|
Unproven bool `json:"unproven,omitempty"`
|
||||||
|
UnprovenReason string `json:"unproven_reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage path kinds. A DRIVE is a physical disk with the full enroll/eject/decommission/migrate/wipe
|
// Storage path kinds. A DRIVE is a physical disk with the full enroll/eject/decommission/migrate/wipe
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package stacks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// D4 (felhom.eu architecture/16-storage-physical-identity.md §5, R-1068): a data-folder or media app on a
|
||||||
|
// box with NO drive is refused in words with the way forward — never the field name, never sys_drive.
|
||||||
|
// COMPANION RED-PROOF: delete the D4 branch in deploy.go → the plain required-field refusal comes back
|
||||||
|
// („a(z) "Médiatár útvonal" (HDD_PATH) mező kitöltése kötelező", the OMV test's sentence) and this fails.
|
||||||
|
func TestD4_NoDriveRefusalSpeaksInWords(t *testing.T) {
|
||||||
|
for name, yml := range map[string]string{
|
||||||
|
"media app": "display_name: Jellyfin\nmedia_library:\n folder: films\ndeploy_fields:\n - env_var: HDD_PATH\n label: Médiatár útvonal\n type: path\n required: true\n",
|
||||||
|
"data-folder app": "display_name: Paperless\ndeploy_fields:\n - env_var: HDD_PATH\n label: Adattárolási útvonal\n type: path\n required: true\n",
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
m := r553Manager(t, yml)
|
||||||
|
_, err := m.DeployStack(DeployRequest{StackName: "r553app", Values: map[string]string{}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("deploy was admitted on a box with no drive")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNeedsDrive) {
|
||||||
|
t.Fatalf("want ErrNeedsDrive, got %v", err)
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if msg != "Ehhez az alkalmazáshoz egy adattároló kell. A Meghajtók oldalon adhatsz hozzá egyet." {
|
||||||
|
t.Errorf("the D4 sentence moved: %q", msg)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"HDD_PATH", "útvonal", "sys_drive", "kötelező"} {
|
||||||
|
if strings.Contains(msg, bad) {
|
||||||
|
t.Errorf("the refusal names %q — it must speak in words, not fields: %q", bad, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path field the household FILLED is not a D4 case: a missing path keeps its own refusal.
|
||||||
|
func TestD4_FilledPathKeepsItsOwnRefusal(t *testing.T) {
|
||||||
|
m := r553Manager(t, "display_name: X\ndeploy_fields:\n - env_var: HDD_PATH\n label: Adat\n type: path\n required: true\n")
|
||||||
|
_, err := m.DeployStack(DeployRequest{StackName: "r553app", Values: map[string]string{"HDD_PATH": "/definitely/not/here/d4"}})
|
||||||
|
if !errors.Is(err, ErrPathMissing) {
|
||||||
|
t.Fatalf("want ErrPathMissing for a filled, missing path, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -390,6 +390,14 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D4 (`16` §5, R-1068): a path field left empty on a box with NO drive at all is not a form mistake —
|
||||||
|
// the household has nowhere to put the data. Say so in words with the way forward; never the field
|
||||||
|
// name. (sys_drive is never suggested: it rides in every whole-guest copy, `16` §11.)
|
||||||
|
if field.Required && value == "" && !field.AfterStart && field.Type == "path" && m.defaultMediaDrive() == "" {
|
||||||
|
clearDeploying()
|
||||||
|
m.logger.Printf("[INFO] [stacks] Deploy %s: refused — the box has no drive for %s (D4)", req.StackName, field.EnvVar)
|
||||||
|
return "", util.MsgErrorf(ErrNeedsDrive, "err.stacks.needs_drive")
|
||||||
|
}
|
||||||
// Validate required fields (an after_start field is asked after the start — R-1028)
|
// Validate required fields (an after_start field is asked after the start — R-1028)
|
||||||
if field.Required && value == "" && !field.AfterStart {
|
if field.Required && value == "" && !field.AfterStart {
|
||||||
clearDeploying()
|
clearDeploying()
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ var (
|
|||||||
// deploy's own `up` makes BOTH fail, and the deploy then records „not deployed" over whatever
|
// deploy's own `up` makes BOTH fail, and the deploy then records „not deployed" over whatever
|
||||||
// containers the race left behind. Pinned by TestR634_StopAndStartRefuseADeployingStack.
|
// containers the race left behind. Pinned by TestR634_StopAndStartRefuseADeployingStack.
|
||||||
ErrStackDeploying = errors.New("stack is being deployed")
|
ErrStackDeploying = errors.New("stack is being deployed")
|
||||||
|
// ErrNeedsDrive — a data-folder or media app on a box with NO drive (felhom.eu architecture/
|
||||||
|
// 16-storage-physical-identity.md §5, D4; R-1068). API: 400 with code "needs_drive" and a link to the
|
||||||
|
// drives page; the sentence never names the field. Pinned by TestD4_NoDriveRefusalSpeaksInWords.
|
||||||
|
ErrNeedsDrive = errors.New("a storage drive is needed")
|
||||||
)
|
)
|
||||||
|
|
||||||
// KeyMemoryOvercommitWarning is the soft overcommit warning's bundle key. It travels as a KEY from
|
// KeyMemoryOvercommitWarning is the soft overcommit warning's bundle key. It travels as a KEY from
|
||||||
|
|||||||
@@ -1422,6 +1422,9 @@ type AppBackupRow struct {
|
|||||||
// Tier2UnitStaleNotice (R-403) — non-empty when the newest run PRESERVED this copy's package
|
// Tier2UnitStaleNotice (R-403) — non-empty when the newest run PRESERVED this copy's package
|
||||||
// instead of refreshing it, so the card cannot render a preserved package as a fresh one.
|
// instead of refreshing it, so the card cannot render a preserved package as a fresh one.
|
||||||
Tier2UnitStaleNotice string
|
Tier2UnitStaleNotice string
|
||||||
|
// Tier2UnprovenNotice (v0.317.0, `16` D2) — non-empty when the copy sits on a drive the host agent cannot
|
||||||
|
// prove is another physical disk: the copy exists, it does not count as a second copy.
|
||||||
|
Tier2UnprovenNotice string
|
||||||
|
|
||||||
// Drive disconnected — app's home drive is currently disconnected
|
// Drive disconnected — app's home drive is currently disconnected
|
||||||
DriveDisconnected bool
|
DriveDisconnected bool
|
||||||
@@ -1663,6 +1666,9 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string
|
|||||||
row.Tier2LastStatus = cd.LastStatus
|
row.Tier2LastStatus = cd.LastStatus
|
||||||
row.Tier2LastSuccess = cd.LastSuccess
|
row.Tier2LastSuccess = cd.LastSuccess
|
||||||
row.Tier2SuccessTracked = cd.SuccessTracked
|
row.Tier2SuccessTracked = cd.SuccessTracked
|
||||||
|
if cd.Unproven {
|
||||||
|
row.Tier2UnprovenNotice = s.msgLang(lang, "backup.tier2.unproven")
|
||||||
|
}
|
||||||
// Disclose, do not hide: an older good copy AND the fact that the newest attempt failed.
|
// Disclose, do not hide: an older good copy AND the fact that the newest attempt failed.
|
||||||
// Suppressing the failure to keep the surface calm is a quieter version of the same lie.
|
// Suppressing the failure to keep the surface calm is a quieter version of the same lie.
|
||||||
row.Tier2StaleCopy = cd.SuccessTracked && cd.LastSuccess != "" && cd.LastStatus == "error"
|
row.Tier2StaleCopy = cd.SuccessTracked && cd.LastSuccess != "" && cd.LastStatus == "error"
|
||||||
|
|||||||
@@ -283,6 +283,7 @@
|
|||||||
not render as a plain fresh copy. The status line above is about the RUN;
|
not render as a plain fresh copy. The status line above is about the RUN;
|
||||||
this one is about the PACKAGE, and after a preserved leg they differ. */}}
|
this one is about the PACKAGE, and after a preserved leg they differ. */}}
|
||||||
{{if .Tier2UnitStaleNotice}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2UnitStaleNotice}}</span>{{end}}
|
{{if .Tier2UnitStaleNotice}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2UnitStaleNotice}}</span>{{end}}
|
||||||
|
{{if .Tier2UnprovenNotice}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2UnprovenNotice}}</span>{{end}}
|
||||||
<span class="tier-contents">{{.Tier23Contents}}</span>
|
<span class="tier-contents">{{.Tier23Contents}}</span>
|
||||||
<span class="tier-browsable" title="{{T "backups_apps.a_mentes_bongeszheto_fajlrendszerben"}}"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="{{T "backups_apps.a_mentes_bongeszheto_fajlrendszerben"}}"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
|
|||||||
@@ -431,6 +431,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// D4 (`16` §5): no drive on the box — the sentence, and a way to the drives page.
|
||||||
|
if (!data.ok && data.data && data.data.code === 'needs_drive') {
|
||||||
|
var nd = window.needsDriveNote;
|
||||||
|
if (!nd) {
|
||||||
|
nd = document.createElement('div');
|
||||||
|
nd.className = 'alert alert-warning';
|
||||||
|
btn.parentNode.insertBefore(nd, btn);
|
||||||
|
window.needsDriveNote = nd;
|
||||||
|
}
|
||||||
|
nd.textContent = data.error + ' ';
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = data.data.link || '/storage';
|
||||||
|
a.textContent = '{{T "deploy.meghajtok_megnyitasa"}}';
|
||||||
|
nd.appendChild(a);
|
||||||
|
btn.textContent = '{{T "deploy.telepites_inditasa"}}';
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
showAlert('{{T "common.hiba_kettospont"}} ' + data.error);
|
showAlert('{{T "common.hiba_kettospont"}} ' + data.error);
|
||||||
btn.textContent = '{{T "deploy.telepites_inditasa"}}';
|
btn.textContent = '{{T "deploy.telepites_inditasa"}}';
|
||||||
|
|||||||
@@ -819,6 +819,7 @@
|
|||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
||||||
|
|
||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -902,6 +903,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -978,6 +980,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -1047,6 +1050,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
|
|||||||
+4
@@ -850,6 +850,7 @@
|
|||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
||||||
|
|
||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -933,6 +934,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -1009,6 +1011,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -1078,6 +1081,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
|
|||||||
@@ -854,6 +854,7 @@
|
|||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
||||||
|
|
||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -937,6 +938,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -1013,6 +1015,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -1082,6 +1085,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
||||||
|
|
||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -931,6 +932,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -1007,6 +1009,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -1076,6 +1079,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
|
|||||||
@@ -762,6 +762,7 @@
|
|||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
||||||
|
|
||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -845,6 +846,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -921,6 +923,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -990,6 +993,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
|
|||||||
@@ -762,6 +762,7 @@
|
|||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
||||||
|
|
||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -845,6 +846,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -921,6 +923,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -990,6 +993,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
|
||||||
|
|
||||||
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -931,6 +932,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -1007,6 +1009,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
@@ -1076,6 +1079,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<span class="tier-contents">Teljes helyreállítási egység</span>
|
<span class="tier-contents">Teljes helyreállítási egység</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
|
|||||||
@@ -814,6 +814,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.ok && data.data && data.data.code === 'needs_drive') {
|
||||||
|
var nd = window.needsDriveNote;
|
||||||
|
if (!nd) {
|
||||||
|
nd = document.createElement('div');
|
||||||
|
nd.className = 'alert alert-warning';
|
||||||
|
btn.parentNode.insertBefore(nd, btn);
|
||||||
|
window.needsDriveNote = nd;
|
||||||
|
}
|
||||||
|
nd.textContent = data.error + ' ';
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = data.data.link || '/storage';
|
||||||
|
a.textContent = 'Meghajtók megnyitása';
|
||||||
|
nd.appendChild(a);
|
||||||
|
btn.textContent = 'Telepítés indítása';
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
showAlert('Hiba: ' + data.error);
|
showAlert('Hiba: ' + data.error);
|
||||||
btn.textContent = 'Telepítés indítása';
|
btn.textContent = 'Telepítés indítása';
|
||||||
|
|||||||
@@ -768,6 +768,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.ok && data.data && data.data.code === 'needs_drive') {
|
||||||
|
var nd = window.needsDriveNote;
|
||||||
|
if (!nd) {
|
||||||
|
nd = document.createElement('div');
|
||||||
|
nd.className = 'alert alert-warning';
|
||||||
|
btn.parentNode.insertBefore(nd, btn);
|
||||||
|
window.needsDriveNote = nd;
|
||||||
|
}
|
||||||
|
nd.textContent = data.error + ' ';
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = data.data.link || '/storage';
|
||||||
|
a.textContent = 'Meghajtók megnyitása';
|
||||||
|
nd.appendChild(a);
|
||||||
|
btn.textContent = 'Telepítés indítása';
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
showAlert('Hiba: ' + data.error);
|
showAlert('Hiba: ' + data.error);
|
||||||
btn.textContent = 'Telepítés indítása';
|
btn.textContent = 'Telepítés indítása';
|
||||||
|
|||||||
@@ -793,6 +793,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.ok && data.data && data.data.code === 'needs_drive') {
|
||||||
|
var nd = window.needsDriveNote;
|
||||||
|
if (!nd) {
|
||||||
|
nd = document.createElement('div');
|
||||||
|
nd.className = 'alert alert-warning';
|
||||||
|
btn.parentNode.insertBefore(nd, btn);
|
||||||
|
window.needsDriveNote = nd;
|
||||||
|
}
|
||||||
|
nd.textContent = data.error + ' ';
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = data.data.link || '/storage';
|
||||||
|
a.textContent = 'Meghajtók megnyitása';
|
||||||
|
nd.appendChild(a);
|
||||||
|
btn.textContent = 'Telepítés indítása';
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
showAlert('Hiba: ' + data.error);
|
showAlert('Hiba: ' + data.error);
|
||||||
btn.textContent = 'Telepítés indítása';
|
btn.textContent = 'Telepítés indítása';
|
||||||
|
|||||||
@@ -250,7 +250,11 @@
|
|||||||
"app_page.tab_logs": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the „Naplo\" tab's label. The logs PAGE said „Naplok\" (plural); the tab is one app's log, so the word is new, not moved.",
|
"app_page.tab_logs": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the „Naplo\" tab's label. The logs PAGE said „Naplok\" (plural); the tab is one app's log, so the word is new, not moved.",
|
||||||
"err.after_start.failed": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the generic refusal for an after-start action that fails without naming its own sentence. Plex's three messages (err.plex_claim.*) are unchanged; this is the fallback a future app gets for free.",
|
"err.after_start.failed": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the generic refusal for an after-start action that fails without naming its own sentence. Plex's three messages (err.plex_claim.*) are unchanged; this is the fallback a future app gets for free.",
|
||||||
"page.title.app_data": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the data tab's page title. It replaces page.title.tier2_config, whose page no longer exists; the old key was removed in the same commit.",
|
"page.title.app_data": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the data tab's page title. It replaces page.title.tier2_config, whose page no longer exists; the old key was removed in the same commit.",
|
||||||
"page.title.app_guide": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the guide tab's page title. New: the details page it replaces had no title key of its own, it used the app's display name."
|
"page.title.app_guide": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the guide tab's page title. New: the details page it replaces had no title key of its own, it used the app's display name.",
|
||||||
|
"note.backup.single_copy": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the one-copy notice on the backup page (felhom.eu architecture/16-storage-physical-identity.md §4). It replaces the Go constant singleCopyNotice, removed in the same commit; the wording changed ON PURPOSE (the informal voice, R-1072; the honest same-disk sentence, `16` D1).",
|
||||||
|
"note.backup.single_copy_unproven": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the notice when a second drive exists whose disk the host agent cannot prove (a VM, a USB bridge that hides the serial; `16` D2). New.",
|
||||||
|
"backup.tier2.unproven": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the app row's line under a Tier-2 copy on a drive the agent cannot prove is another disk (`16` D2). New.",
|
||||||
|
"err.stacks.needs_drive": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the install refusal on a box with no drive (`16` §5, D4; R-1068). New; it never names the field."
|
||||||
},
|
},
|
||||||
"flash.share.already_on": "A megosztás már be van kapcsolva.",
|
"flash.share.already_on": "A megosztás már be van kapcsolva.",
|
||||||
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
||||||
|
|||||||
Reference in New Issue
Block a user