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

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:
2026-10-15 21:21:54 +02:00
parent c6a567122e
commit 0626b9fc8a
34 changed files with 665 additions and 200 deletions
+29
View File
@@ -355,6 +355,12 @@ type DiskInfo struct {
// 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.
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
@@ -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.
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.
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
var out DisksResponse
+10
View File
@@ -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.
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
// refuses — the existing agent-error paths speak honestly when the agent is down).
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 {
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
@@ -204,6 +212,8 @@ var featureMinAgent = map[Feature]string{
// R-998 — POST /host/poweroff, the household's „A gép kikapcsolása".
FeatureHostPowerOff: "0.155.0",
FeatureSameDisk: "0.157.0",
}
// 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)
}
}
+7 -2
View File
@@ -437,7 +437,7 @@ func deployStatusFor(err error) int {
switch {
case errors.Is(err, stacks.ErrAlreadyDeployed), errors.Is(err, stacks.ErrKeptDataChoice):
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):
return http.StatusBadRequest
}
@@ -535,7 +535,12 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
if err != nil {
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.
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
}
+88 -16
View File
@@ -286,6 +286,11 @@ type Manager struct {
// 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).
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
// 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.
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.
type DBDumpStatus struct {
LastRun time.Time
@@ -1363,6 +1364,59 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
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
// one is installed. Nil seam → system.SamePhysicalDevice, i.e. byte-for-byte the previous behaviour.
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)
}
// hasOffDriveTarget reports whether any registered, schedulable storage path lives on a physical disk
// OTHER than the system drive — i.e. whether a genuine off-drive (tier-2) copy is possible at all.
// When false the box is single-drive: tier-1 is the ONLY local copy and 3-2-1 needs a 2nd drive or
// offsite (F6 — surfaced honestly via SingleCopyWarning, never a faked guarantee).
func (m *Manager) hasOffDriveTarget() bool {
// offDriveCopyState reports whether any registered, schedulable, NON-network storage path is on a physical
// disk OTHER than the system drive — counted only when proven (`16` §4). unknown is true when such a path
// exists but the agent cannot prove it is another disk (a VM, a hidden USB serial). A NAS never counts
// (R-1075: it is never a Tier-2 target, so it must not hide the one-copy notice).
func (m *Manager) offDriveCopyState() (counted, unknown bool) {
if m.settings == nil || m.systemDataPath == "" {
return false
return false, false
}
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
}
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 {
if m.hasOffDriveTarget() {
counted, unknown := m.offDriveCopyState()
switch {
case counted:
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
@@ -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) {
// Single-drive: only the system drive, no schedulable off-drive paths.
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)
}
+67 -16
View File
@@ -67,6 +67,11 @@ type Tier2Target struct {
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
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
@@ -116,47 +121,75 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
sawNetworkCandidate = true
break
}
if sp.Path == sourceDrive || m.sameDevice(sourceDrive, sp.Path) {
break // pinned target is on the same physical disk — not off-drive; fall through
if sp.Path == sourceDrive {
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
if label == "" {
label = filepath.Base(sp.Path)
}
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true),
Label: label,
Reason: m.note("note.tier2.reason_manual"),
NamespaceRoot: NamespaceRoot(sp.Path, true),
Label: label,
Reason: m.note("note.tier2.reason_manual"),
Unproven: rel == DiskUnknown,
UnprovenReason: unprovenWhy(rel, why),
}, 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 {
var firstUnknown *Tier2Target
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if sp.Path == sourceDrive || m.sameDevice(sourceDrive, sp.Path) {
if sp.Path == sourceDrive {
continue
}
if sp.IsNetwork() {
sawNetworkCandidate = true // F-6C-1: never a tier-2 target
continue
}
rel, why := m.diskRelation(sourceDrive, sp.Path)
if rel == DiskSame {
continue
}
label := sp.Label
if label == "" {
label = filepath.Base(sp.Path)
}
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
Label: label,
Reason: m.note("note.tier2.reason_other_drive"),
}, nil
t := &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
Label: label,
Reason: m.note("note.tier2.reason_other_drive"),
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.
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 {
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,
StateOnly: true,
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
}
// 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.
func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool {
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)
return nil
}
// Defense-in-depth off-drive guard (selection already enforced it).
if m.sameDevice(sourceDrive, target.NamespaceRoot) {
// Defense-in-depth off-drive guard (selection already enforced it) — asked again right before the run,
// 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"))
return nil
}
@@ -527,7 +572,10 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info {
}
// Eligible alternative drives: registered, schedulable, on a DIFFERENT physical disk.
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
}
label := sp.Label
@@ -636,6 +684,9 @@ func (m *Manager) recordTier2SuccessWithUnit(stackName string, target *Tier2Targ
c.LastDuration = dur.Round(time.Second).String()
c.LastSizeHuman = humanizeBytes(sizeBytes)
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
})
}
+1 -1
View File
@@ -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
// 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))
continue
}
+5
View File
@@ -205,6 +205,7 @@
"backup.tier2.badge_running": "Running...",
"backup.tier2.dest_ssd": "internal SSD (database and settings only)",
"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",
"backups.a_kijeloles_nem_sikerult": "The drive could not be assigned:",
"backups.a_meghajto_kijelolve": "The drive is assigned.",
@@ -1192,6 +1193,7 @@
"deploy.telepites": "Install",
"deploy.telepites_folyamatban": "Installing...",
"deploy.telepites_inditasa": "Start install",
"deploy.meghajtok_megnyitasa": "Open Drives",
"deploy.telepites_sikeres": "Install succeeded!",
"deploy.telepites_sikertelen": "Install failed",
"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_mentes": "the check failed (%s backup): %s",
"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.forras_nevter_olvasasa_sikertelen": "reading the source folder failed: %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_other_drive": "another data drive",
"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.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.",
+5
View File
@@ -201,6 +201,7 @@
"backup.tier2.badge_running": "Fut...",
"backup.tier2.dest_ssd": "belső SSD (csak DB/konfiguráció)",
"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",
"backups.a_kijeloles_nem_sikerult": "A kijelölés nem sikerült:",
"backups.a_meghajto_kijelolve": "A meghajtó kijelölve.",
@@ -1187,6 +1188,7 @@
"deploy.telepites": "Telepítés",
"deploy.telepites_folyamatban": "Telepítés folyamatban...",
"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_sikertelen": "Telepítés sikertelen",
"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_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.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.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",
@@ -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_other_drive": "másik adatmeghajtó",
"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.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.",
+4
View File
@@ -550,6 +550,10 @@ type CrossDriveBackup struct {
// destination drive (a registered storage Path) instead of the auto-pick ("" = auto).
UserDisabled bool `json:"user_disabled,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
@@ -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)
}
}
+8
View File
@@ -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)
if field.Required && value == "" && !field.AfterStart {
clearDeploying()
@@ -28,6 +28,10 @@ var (
// 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.
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
+6
View File
@@ -1422,6 +1422,9 @@ type AppBackupRow struct {
// 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.
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
DriveDisconnected bool
@@ -1663,6 +1666,9 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string
row.Tier2LastStatus = cd.LastStatus
row.Tier2LastSuccess = cd.LastSuccess
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.
// 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"
@@ -283,6 +283,7 @@
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. */}}
{{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-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">
@@ -431,6 +431,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
btn.disabled = false;
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) {
showAlert('{{T "common.hiba_kettospont"}} ' + data.error);
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">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-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">
@@ -902,6 +903,7 @@
<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>
<div class="layer-actions">
@@ -978,6 +980,7 @@
<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>
<div class="layer-actions">
@@ -1047,6 +1050,7 @@
<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>
<div class="layer-actions">
@@ -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">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-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">
@@ -933,6 +934,7 @@
<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>
<div class="layer-actions">
@@ -1009,6 +1011,7 @@
<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>
<div class="layer-actions">
@@ -1078,6 +1081,7 @@
<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>
<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">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-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">
@@ -937,6 +938,7 @@
<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>
<div class="layer-actions">
@@ -1013,6 +1015,7 @@
<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>
<div class="layer-actions">
@@ -1082,6 +1085,7 @@
<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>
<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">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-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">
@@ -931,6 +932,7 @@
<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>
<div class="layer-actions">
@@ -1007,6 +1009,7 @@
<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>
<div class="layer-actions">
@@ -1076,6 +1079,7 @@
<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>
<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">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-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">
@@ -845,6 +846,7 @@
<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>
<div class="layer-actions">
@@ -921,6 +923,7 @@
<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>
<div class="layer-actions">
@@ -990,6 +993,7 @@
<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>
<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">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-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">
@@ -845,6 +846,7 @@
<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>
<div class="layer-actions">
@@ -921,6 +923,7 @@
<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>
<div class="layer-actions">
@@ -990,6 +993,7 @@
<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>
<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">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-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">
@@ -931,6 +932,7 @@
<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>
<div class="layer-actions">
@@ -1007,6 +1009,7 @@
<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>
<div class="layer-actions">
@@ -1076,6 +1079,7 @@
<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>
<div class="layer-actions">
@@ -814,6 +814,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
btn.disabled = false;
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) {
showAlert('Hiba: ' + data.error);
btn.textContent = 'Telepítés indítása';
@@ -768,6 +768,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
btn.disabled = false;
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) {
showAlert('Hiba: ' + data.error);
btn.textContent = 'Telepítés indítása';
@@ -793,6 +793,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
btn.disabled = false;
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) {
showAlert('Hiba: ' + data.error);
btn.textContent = 'Telepítés indítása';