v0.318.0: the internal data place, partitions, an honest whole-box backup offer (R-1084), sys_drive never offered (R-1082)
gates / gates (push) Successful in 46s
gates / gates (push) Successful in 46s
felhom.eu architecture/16 §4-§6 (build B). MinAgent 0.157.0 — the internal place and the partitions appear only when agent >= 0.158.0 reports them. Tests red-proved; four parity fixtures refreshed (only the new lines). Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
@@ -357,12 +357,16 @@ type DiskInfo struct {
|
||||
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"`
|
||||
Kind string `json:"kind,omitempty"` // system | internal | disk | partition | network (felhom.eu `16` §3)
|
||||
PhysicalDisks []string `json:"physical_disks,omitempty"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
IdentityReason string `json:"identity_reason,omitempty"`
|
||||
}
|
||||
|
||||
// PlaceKindInternal is the agent's kind for the one-disk box's internal data place („Belső tárhely", agent ≥ 0.158.0,
|
||||
// felhom.eu architecture/16 §5): a host mount on the system disk, bound at /mnt/felhom-drives/belso.
|
||||
const PlaceKindInternal = "internal"
|
||||
|
||||
// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity
|
||||
// is not a filesystem UUID (network/lvm targets — not assignable as a host mount).
|
||||
func (d DiskInfo) FSUUID() string {
|
||||
@@ -477,8 +481,34 @@ type DiskCandidate struct {
|
||||
// mountpoint — sending it down the device-attach path would try to mount an in-guest path as if
|
||||
// it were a raw device. See web/attach_sources.go for why the agent's scan cannot supply these.
|
||||
AlreadyMounted bool `json:"already_mounted,omitempty"`
|
||||
// Partitions (agent ≥ 0.158.0, felhom.eu architecture/16 §6): each partition of this disk as its own place —
|
||||
// state empty („format this part"), filesystem („use this part" when mountable; format through the confirmed
|
||||
// path), in_use (already a Felhom drive: shown, never offered). Absent from an older agent.
|
||||
Partitions []CandidatePartition `json:"partitions,omitempty"`
|
||||
// WholeDiskInUse is set by the CONTROLLER (never the agent) when a partition of this disk is a registered drive
|
||||
// (R-542): the disk stays listed for its FREE parts only, and no whole-disk action is offered.
|
||||
WholeDiskInUse bool `json:"whole_disk_in_use,omitempty"`
|
||||
}
|
||||
|
||||
// CandidatePartition mirrors the agent's partition entry (felhom-agent internal/storage/partitions.go).
|
||||
type CandidatePartition struct {
|
||||
Device string `json:"device"`
|
||||
Number int `json:"number,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
FSType string `json:"fstype,omitempty"`
|
||||
State string `json:"state"` // empty | filesystem | in_use
|
||||
Mountable bool `json:"mountable"`
|
||||
DurableID string `json:"durable_id,omitempty"` // uuid:<fs-uuid> — the attach key
|
||||
MountedAt string `json:"mounted_at,omitempty"`
|
||||
}
|
||||
|
||||
// Partition states (agent ≥ 0.158.0).
|
||||
const (
|
||||
PartEmpty = "empty"
|
||||
PartFilesystem = "filesystem"
|
||||
PartInUse = "in_use"
|
||||
)
|
||||
|
||||
// CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all
|
||||
// unclaimed) and attach (the mountable-FS subset).
|
||||
type CandidatesResult struct {
|
||||
|
||||
@@ -1362,6 +1362,12 @@
|
||||
"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.",
|
||||
"storage_wizard.resz_n": "part {n}",
|
||||
"err.web.meghajto_hasznalatban_formazas": "This disk holds a drive that is in use, so the whole disk cannot be formatted. You can format only its free part.",
|
||||
"storage.belso_tarhely_magyarazat": "It is on the machine's own disk, but it is not part of the full system backup. A different disk or the off-site backup protects against a disk failure.",
|
||||
"backup.target.offer_unproven": "You can choose this drive as the place for the system backup. 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 we cannot promise that you can restore the system after a disk failure.",
|
||||
"storage.belso_tarhely": "Internal storage",
|
||||
"err.web.belso_tarhely_eletciklus": "The internal storage is on the disk of the machine itself, so it cannot be removed, retired or formatted. You can move its data to another drive.",
|
||||
"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",
|
||||
|
||||
@@ -1355,6 +1355,12 @@
|
||||
"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.",
|
||||
"storage_wizard.resz_n": "{n}. rész",
|
||||
"err.web.meghajto_hasznalatban_formazas": "Ezen a lemezen egy használatban lévő meghajtó van, ezért a teljes lemez nem formázható. Csak a szabad részét formázhatod.",
|
||||
"storage.belso_tarhely_magyarazat": "A gép saját lemezén van, de nem része a teljes rendszermentésnek. Egy lemezhiba ellen egy másik lemez vagy a távoli mentés véd.",
|
||||
"backup.target.offer_unproven": "Ezt a meghajtót kijelölheted a rendszermentés helyéül. 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 nem ígérhetjük, hogy egy lemezhiba után is vissza tudod állítani a rendszert.",
|
||||
"storage.belso_tarhely": "Belső tárhely",
|
||||
"err.web.belso_tarhely_eletciklus": "A Belső tárhely a gép saját lemezén van, ezért nem lehet leválasztani, leszerelni vagy formázni. Az adatait áthelyezheted egy másik meghajtóra.",
|
||||
"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",
|
||||
|
||||
@@ -563,6 +563,11 @@ type CrossDriveBackup struct {
|
||||
const (
|
||||
StorageKindDrive = "drive"
|
||||
StorageKindNetwork = "network"
|
||||
// StorageKindInternal (v0.318.0, felhom.eu architecture/16 §5) is the one-disk box's internal data place
|
||||
// („Belső tárhely"): a volume on the SYSTEM disk the agent mounts like a drive. Apps and their data live there;
|
||||
// the drive lifecycle that would remove or format it (eject, decommission, wipe) does not apply — moving its data
|
||||
// to another drive does.
|
||||
StorageKindInternal = "internal"
|
||||
)
|
||||
|
||||
// NetworkMountRoot is the in-guest path under which the agent propagates NAS shares (mirrors the agent's
|
||||
@@ -635,6 +640,9 @@ type StoragePath struct {
|
||||
// lifecycle (eject/decommission/migrate/wipe/SMART) must NEVER be applied to a network path.
|
||||
func (p StoragePath) IsNetwork() bool { return p.Kind == StorageKindNetwork }
|
||||
|
||||
// IsInternal reports whether this is the internal data place (`16` §5).
|
||||
func (p StoragePath) IsInternal() bool { return p.Kind == StorageKindInternal }
|
||||
|
||||
// NotificationPrefs holds customer notification preferences.
|
||||
type NotificationPrefs struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
|
||||
@@ -2,8 +2,13 @@ package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// D4 (felhom.eu architecture/16-storage-physical-identity.md §5, R-1068): a data-folder or media app on a
|
||||
@@ -45,3 +50,33 @@ func TestD4_FilledPathKeepsItsOwnRefusal(t *testing.T) {
|
||||
t.Fatalf("want ErrPathMissing for a filled, missing path, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// `16` §5 (v0.318.0): on a one-disk box with the internal place registered, a media app installs with NO extra step
|
||||
// and NO sentence — its HDD_PATH lands on the internal place. Companion of TestD4_NoDriveRefusalSpeaksInWords (the
|
||||
// same app, no drive → the refusal).
|
||||
func TestD4_InternalPlaceIsTheMediaAppsDrive(t *testing.T) {
|
||||
m := r553Manager(t, "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")
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
belso := filepath.Join(t.TempDir(), "belso")
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: belso, IsDefault: true, Schedulable: true, Kind: settings.StorageKindInternal}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.settings = sett
|
||||
m.isMountPoint = func(p string) bool { return filepath.Clean(p) == belso }
|
||||
if got := m.defaultMediaDrive(); got != belso {
|
||||
t.Fatalf("the default media drive: want the internal place %q, got %q", belso, got)
|
||||
}
|
||||
// The folder is deliberately NOT created: the deploy then stops at its path check, so no real `docker compose`
|
||||
// can ever start on the machine running this test (tests that reach real docker act on DooPlex, R-650). The
|
||||
// refusal naming the internal place proves HDD_PATH was filled with it — the step the household no longer does.
|
||||
_, err = m.DeployStack(DeployRequest{StackName: "r553app", Values: map[string]string{}})
|
||||
if errors.Is(err, ErrNeedsDrive) {
|
||||
t.Fatalf("a box with the internal place must not be told it needs a drive: %v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrPathMissing) || !strings.Contains(err.Error(), belso) {
|
||||
t.Fatalf("want the media app's HDD_PATH filled with the internal place %q, got %v", belso, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,12 @@ func dropRegisteredDrives(resp agentapi.CandidatesResult, mountsText string, reg
|
||||
out := make([]agentapi.DiskCandidate, 0, len(in))
|
||||
for _, c := range in {
|
||||
if candidateBacksInUse(c, inUse) {
|
||||
// `16` §6 (v0.318.0): a disk whose ONE part is a registered drive keeps its FREE parts on offer —
|
||||
// the whole disk itself, never (WholeDiskInUse; handleStorageInit refuses it server-side too).
|
||||
if free := freePartitions(c.Partitions, inUse); len(free) > 0 {
|
||||
c.Partitions, c.WholeDiskInUse = free, true
|
||||
out = append(out, c)
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
@@ -201,6 +207,34 @@ func dropRegisteredDrives(resp agentapi.CandidatesResult, mountsText string, reg
|
||||
return resp
|
||||
}
|
||||
|
||||
// deviceBacksRegistered reports whether formatting `device` would touch a registered drive: the device itself, or a
|
||||
// whole disk one of whose parts is registered (the same join as dropRegisteredDrives). FAIL-SAFE: an unreadable mount
|
||||
// table cannot prove the device free → true.
|
||||
func deviceBacksRegistered(device, mountsText string, registered map[string]bool) bool {
|
||||
if strings.TrimSpace(mountsText) == "" {
|
||||
return true
|
||||
}
|
||||
inUse := map[string]bool{}
|
||||
for _, row := range parseMountTable(mountsText) {
|
||||
if registered[path.Clean(row[1])] && strings.HasPrefix(row[0], "/dev/") {
|
||||
inUse[row[0]] = true
|
||||
}
|
||||
}
|
||||
return candidateBacksInUse(agentapi.DiskCandidate{Device: device}, inUse)
|
||||
}
|
||||
|
||||
// freePartitions keeps the parts that are not a registered drive and not already Felhom's (state in_use).
|
||||
func freePartitions(parts []agentapi.CandidatePartition, inUse map[string]bool) []agentapi.CandidatePartition {
|
||||
var out []agentapi.CandidatePartition
|
||||
for _, p := range parts {
|
||||
if p.State == agentapi.PartInUse || inUse[p.Device] {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// candidateBacksInUse reports whether a candidate's disk is a source in inUse: the whole disk itself,
|
||||
// its mountable partition, or any partition of it (/dev/sdb1, /dev/nvme0n1p1 under /dev/sdb, /dev/nvme0n1).
|
||||
func candidateBacksInUse(c agentapi.DiskCandidate, inUse map[string]bool) bool {
|
||||
|
||||
@@ -143,6 +143,14 @@ func readMountTable() string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// mountTable is readMountTable through the mountsFn test seam (nil → the controller's real /proc/mounts).
|
||||
func (s *Server) mountTable() string {
|
||||
if s.mountsFn != nil {
|
||||
return s.mountsFn()
|
||||
}
|
||||
return readMountTable()
|
||||
}
|
||||
|
||||
// registeredStoragePaths is the set of paths already in the registry, for the exclusion above.
|
||||
func (s *Server) registeredStoragePaths() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
@@ -159,5 +167,41 @@ func (s *Server) registeredStoragePaths() map[string]bool {
|
||||
// precondition read, so the picker and the sentence pointing at it cannot disagree — the same
|
||||
// single-derivation rule R-252 applied to HasRestoreDestination.
|
||||
func (s *Server) attachableStores() []mountedStore {
|
||||
return mountedUnregisteredStores(readMountTable(), s.registeredStoragePaths())
|
||||
return s.attachableStoresFrom(readMountTable())
|
||||
}
|
||||
|
||||
// attachableStoresFrom is attachableStores for a given mount table (the seam its tests use).
|
||||
func (s *Server) attachableStoresFrom(mounts string) []mountedStore {
|
||||
sdp := ""
|
||||
if s.cfg != nil {
|
||||
sdp = s.cfg.Paths.SystemDataPath
|
||||
}
|
||||
return withoutSystemVolume(mountedUnregisteredStores(mounts, s.registeredStoragePaths()), mounts, sdp)
|
||||
}
|
||||
|
||||
// withoutSystemVolume drops every offered store that lives on the guest's OWN data volume (R-1082, operator ruling
|
||||
// 2026-10-16 „hide it now"; felhom.eu architecture/16 §11): /mnt/sys_drive is a folder of that volume, which rides in
|
||||
// every whole-box copy, local and off-site — films put there would fill them. Excluded by DEVICE, like the rootfs
|
||||
// guard above, so a second bind of the same volume under another /mnt/<name> is hidden too. A box that already
|
||||
// REGISTERED sys_drive keeps it: this filters only what is OFFERED. systemDataPath "" falls back to the config
|
||||
// default /mnt/sys_drive (fail toward hiding). Pinned by r1082_sysdrive_hidden_test.go.
|
||||
func withoutSystemVolume(stores []mountedStore, mountsText, systemDataPath string) []mountedStore {
|
||||
if systemDataPath == "" {
|
||||
systemDataPath = "/mnt/sys_drive"
|
||||
}
|
||||
volumePaths := map[string]bool{path.Clean(systemDataPath): true, "/var/lib/felhom": true, "/var/lib/docker": true}
|
||||
volumeDevices := map[string]bool{}
|
||||
for _, row := range parseMountTable(mountsText) {
|
||||
if volumePaths[path.Clean(row[1])] {
|
||||
volumeDevices[row[0]] = true
|
||||
}
|
||||
}
|
||||
out := stores[:0:0]
|
||||
for _, m := range stores {
|
||||
if volumeDevices[m.Device] || volumePaths[path.Clean(m.Path)] {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ type BackupTargetState struct {
|
||||
// was told the backup was on the system disk (false) and offered the drive that just disappeared
|
||||
// (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.3).
|
||||
TargetAbsent bool
|
||||
// OfferUnproven (R-1084) marks an offered drive the agent cannot prove is another disk than the system data:
|
||||
// the page then makes no disk-failure promise.
|
||||
OfferUnproven bool
|
||||
// TargetID is the agent's primary tier storage id (e.g. "felhom-backup" / "local").
|
||||
TargetID string
|
||||
// Label is the customer-facing drive name when the target is a real drive.
|
||||
@@ -95,7 +98,7 @@ func (s *Server) resolveBackupTargetState(ctx context.Context) BackupTargetState
|
||||
// this state must be correct on its own. Belt here, braces there.
|
||||
return st
|
||||
}
|
||||
st.OfferPath, st.OfferLabel = s.firstOfferableDrive(disks.Disks)
|
||||
st.OfferPath, st.OfferLabel, st.OfferUnproven = s.firstOfferableDrive(ctx, disks.Disks)
|
||||
return st
|
||||
}
|
||||
|
||||
@@ -127,20 +130,81 @@ func targetIsConfiguredDrive(targetID string) bool {
|
||||
// firstOfferableDrive picks a registered, connected, non-network drive that could hold the backup.
|
||||
// It is a SUGGESTION for the offer — the customer still has to choose (E-2 §3). Nothing here assigns
|
||||
// anything.
|
||||
func (s *Server) firstOfferableDrive(disks []agentapi.DiskInfo) (path, label string) {
|
||||
//
|
||||
// R-1084 (v0.318.0, felhom.eu architecture/16 §4): the offer promises survival of a DISK failure, so it now asks the
|
||||
// agent whether the drive is really another disk than the system data. A proven different disk wins; a drive the
|
||||
// agent cannot prove (a VM, a USB bridge that hides the serial) is still offered, but unproven=true makes the page say
|
||||
// so in build A's words instead of the promise; a drive on the SAME disk is never offered (a copy there dies with the
|
||||
// disk); an agent older than 0.157.0 keeps today's offer. Pinned by r1084_offer_identity_test.go.
|
||||
func (s *Server) firstOfferableDrive(ctx context.Context, disks []agentapi.DiskInfo) (path, label string, unproven bool) {
|
||||
fallback, fallbackLabel, fallbackUnproven := "", "", false
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.IsNetwork() || sp.Decommissioned || sp.Disconnected {
|
||||
if sp.IsNetwork() || sp.IsInternal() || sp.Decommissioned || sp.Disconnected {
|
||||
continue
|
||||
}
|
||||
for _, d := range disks {
|
||||
// Only a drive the agent classifies as user-data with its OWN mountpoint can be a target;
|
||||
// the agent refuses anything else anyway, so offering it would be a dead end.
|
||||
if d.GuestPath == sp.Path && d.MountPath != "" && d.Role == "user-data" {
|
||||
return sp.Path, s.storageLabelFor(sp.Path)
|
||||
if d.GuestPath != sp.Path || d.MountPath == "" || d.Role != "user-data" {
|
||||
continue
|
||||
}
|
||||
switch s.offerRelationFor(ctx, sp.Path) {
|
||||
case offerDifferent:
|
||||
return sp.Path, s.storageLabelFor(sp.Path), false
|
||||
case offerSame:
|
||||
continue
|
||||
case offerUnknown:
|
||||
if fallback == "" {
|
||||
fallback, fallbackLabel, fallbackUnproven = sp.Path, s.storageLabelFor(sp.Path), true
|
||||
}
|
||||
default: // offerLegacy: today's behaviour
|
||||
if fallback == "" {
|
||||
fallback, fallbackLabel, fallbackUnproven = sp.Path, s.storageLabelFor(sp.Path), false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
return fallback, fallbackLabel, fallbackUnproven
|
||||
}
|
||||
|
||||
// offerRelation is the agent's verdict on a candidate backup drive against the system data (R-1084).
|
||||
type offerRelation int
|
||||
|
||||
const (
|
||||
offerLegacy offerRelation = iota // the agent cannot judge (older than 0.157.0): today's offer, today's words
|
||||
offerDifferent // proven different disks: the disk-failure promise holds
|
||||
offerUnknown // not proven (a VM, a hiding USB bridge, or the question failed): no promise
|
||||
offerSame // the same physical disk: never offered
|
||||
)
|
||||
|
||||
// offerRelationFor asks the agent (POST /disks/same-disk) through the sameDiskFn seam (nil → the real client).
|
||||
func (s *Server) offerRelationFor(ctx context.Context, drive string) offerRelation {
|
||||
sdp := "/mnt/sys_drive"
|
||||
if s.cfg != nil && s.cfg.Paths.SystemDataPath != "" {
|
||||
sdp = s.cfg.Paths.SystemDataPath
|
||||
}
|
||||
if s.sameDiskFn != nil {
|
||||
return s.sameDiskFn(ctx, drive, sdp)
|
||||
}
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
return offerLegacy
|
||||
}
|
||||
if client.Supports(ctx, agentapi.FeatureSameDisk) == agentapi.SupportNo {
|
||||
return offerLegacy
|
||||
}
|
||||
r, err := client.SameDisk(ctx, drive, sdp)
|
||||
switch {
|
||||
case err != nil:
|
||||
s.logger.Printf("[WARN] [backup-target] same-disk question for %s failed: %v — the offer makes no promise", drive, err)
|
||||
return offerUnknown
|
||||
case r.Proven && r.Different:
|
||||
return offerDifferent
|
||||
case r.Proven:
|
||||
return offerSame
|
||||
default:
|
||||
return offerUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// baseName is path.Base without importing path into this file's surface.
|
||||
@@ -171,6 +235,9 @@ func baseName(p string) string {
|
||||
const (
|
||||
backupTargetDegradedKey = "backup.target.degraded"
|
||||
backupTargetOfferKey = "backup.target.offer"
|
||||
// backupTargetOfferUnprovenKey (R-1084, v0.318.0): the offer for a drive the agent cannot prove is another disk —
|
||||
// build A's words, no disk-failure promise.
|
||||
backupTargetOfferUnprovenKey = "backup.target.offer_unproven"
|
||||
// backupTargetAbsentText (R-114) is the CONFIGURED-BUT-GONE state. It is VERBATIM the hub's
|
||||
// customerMessages["backup_target_absent"] (felhom.eu hub/internal/notify/templates.go:93) so the
|
||||
// banner a customer reads on the page and the email they receive say exactly the same thing — a
|
||||
@@ -245,7 +312,11 @@ func (s *Server) backupTargetView(ctx context.Context, lang string) *BackupTarge
|
||||
}
|
||||
v := &BackupTargetView{Message: s.msgLang(lang, key)}
|
||||
if st.OfferPath != "" {
|
||||
v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, s.msgLang(lang, backupTargetOfferKey)
|
||||
offerKey := backupTargetOfferKey
|
||||
if st.OfferUnproven {
|
||||
offerKey = backupTargetOfferUnprovenKey // R-1084: no disk-failure promise for an unproven disk
|
||||
}
|
||||
v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, s.msgLang(lang, offerKey)
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -264,8 +335,13 @@ func (s *Server) handleBackupTargetState(w http.ResponseWriter, r *http.Request)
|
||||
out["message"] = s.msg(r, degradedMessageFor(st))
|
||||
if st.OfferPath != "" {
|
||||
out["offer_path"] = st.OfferPath
|
||||
out["offer_unproven"] = st.OfferUnproven // R-1084
|
||||
out["offer_label"] = st.OfferLabel
|
||||
out["offer_message"] = s.msg(r, backupTargetOfferKey)
|
||||
offerKey := backupTargetOfferKey
|
||||
if st.OfferUnproven {
|
||||
offerKey = backupTargetOfferUnprovenKey // R-1084
|
||||
}
|
||||
out["offer_message"] = s.msg(r, offerKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ func i18nStoragePaths() []m {
|
||||
"AppCount": 0, "IsEnrolled": false},
|
||||
{"Path": "/mnt/hdd3", "Label": "HDD három", "IsDefault": false, "Schedulable": true, "IsMounted": true,
|
||||
"DiskInfo": m{"UsedHuman": "10 GB", "TotalHuman": "1 TB", "UsedPercent": 1.0}, "AppCount": 0, "IsEnrolled": true},
|
||||
// v0.318.0 (`16` §5): the internal data place — its one line, and no safe-disconnect or retire button even
|
||||
// where the card would otherwise carry one (IsUSB).
|
||||
{"Path": "/mnt/felhom-drives/belso", "Label": "Belső tárhely", "IsDefault": false, "Schedulable": true, "IsMounted": true,
|
||||
"IsInternal": true, "IsUSB": true, "AppCount": 0, "IsEnrolled": true},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -313,6 +313,7 @@ func (s *Server) ReconcileDriveGates() {
|
||||
return
|
||||
}
|
||||
s.reconcileEjectedDrives(ctx, agent, resp.Disks)
|
||||
s.ensureInternalPlace(ctx, agent, resp.Disks) // v0.318.0 `16` §5: the one-disk box's data place, no press needed
|
||||
for _, a := range planDriveGates(s.settings.GetStoragePaths(), resp.Disks) {
|
||||
switch {
|
||||
case a.Stop:
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// The internal data place („Belső tárhely", v0.318.0) — felhom.eu architecture/16-storage-physical-identity.md §5.
|
||||
//
|
||||
// On a one-disk box the installer (or an operator's signed job) makes a second volume on the system disk, and the
|
||||
// agent (≥ 0.158.0) mounts it like a drive and reports it in GET /disks with kind „internal". The controller then,
|
||||
// with no household press:
|
||||
// 1. asks the agent to bind it into this guest (POST /disks/guest-attach — scoped to this guest's own token), and
|
||||
// 2. on the next drive-gate tick, once the bind is live, registers it as a drive of kind internal — the DEFAULT
|
||||
// drive when no other drive is default — so media and data-folder apps install with no extra step and no
|
||||
// sentence (R-1068; the D4 refusal stays for a box with no drive at all).
|
||||
//
|
||||
// It is never offered for the drive lifecycle that would remove or format it (eject, decommission, wipe —
|
||||
// refuseInternalLifecycle); the agent refuses those too (its device is on the system disk). Moving its data to
|
||||
// another drive stays allowed. Its physical disk is the system disk, so build A never counts a copy between it and
|
||||
// sys_drive as a second copy. Pinned by internal_place_test.go.
|
||||
|
||||
// internalPlaceAgent is the one agent call this needs (a seam for tests).
|
||||
type internalPlaceAgent interface {
|
||||
GuestAttach(ctx context.Context, where string) error
|
||||
}
|
||||
|
||||
// ensureInternalPlace runs on every drive-gate tick (ReconcileDriveGates) with the agent's current /disks rows.
|
||||
func (s *Server) ensureInternalPlace(ctx context.Context, agent internalPlaceAgent, disks []agentapi.DiskInfo) {
|
||||
if s.settings == nil {
|
||||
return
|
||||
}
|
||||
for _, d := range disks {
|
||||
if d.Kind != agentapi.PlaceKindInternal || d.MountPath == "" || d.GuestPath == "" {
|
||||
continue
|
||||
}
|
||||
gp := path.Clean(d.GuestPath)
|
||||
if !d.BoundUnderParent {
|
||||
// Bind it now; register on a later tick, once the bind is live (the skeleton is made in-guest).
|
||||
if err := agent.GuestAttach(ctx, d.MountPath); err != nil {
|
||||
s.logger.Printf("[WARN] [internal-place] guest bind of %s failed: %v", d.MountPath, err)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [internal-place] bound %s into the guest at %s", d.MountPath, gp)
|
||||
return
|
||||
}
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if path.Clean(sp.Path) == gp {
|
||||
return // registered already (by us, or by the household on an older controller)
|
||||
}
|
||||
}
|
||||
if err := s.registerInternalPlace(gp); err != nil {
|
||||
s.logger.Printf("[WARN] [internal-place] register %s failed: %v", gp, err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// registerInternalPlace records the internal place as a drive of kind internal — the default drive unless the
|
||||
// household already has one.
|
||||
func (s *Server) registerInternalPlace(where string) error {
|
||||
setDefault := true
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.IsDefault && !sp.IsNetwork() && !sp.Decommissioned {
|
||||
setDefault = false
|
||||
}
|
||||
}
|
||||
if err := s.ensureUserdataSkeleton(where); err != nil {
|
||||
s.logger.Printf("[WARN] [internal-place] userdata skeleton on %s: %v", where, err)
|
||||
}
|
||||
sp := settings.StoragePath{
|
||||
Path: where,
|
||||
Label: s.msgLang(s.settings.GetLanguage(), "storage.belso_tarhely"),
|
||||
IsDefault: setDefault,
|
||||
Schedulable: true,
|
||||
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Kind: settings.StorageKindInternal,
|
||||
}
|
||||
if err := s.settings.AddStoragePath(sp); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Printf("[INFO] [internal-place] registered %s (default=%v) — media and data-folder apps can use it", where, setDefault)
|
||||
go s.SyncFileBrowserMounts()
|
||||
s.mediaDriveEvent(where, "added")
|
||||
return nil
|
||||
}
|
||||
|
||||
// refuseInternalLifecycle blocks eject, decommission and wipe of the internal place (it is part of the system disk).
|
||||
// Returns true (and writes the refusal) when the path is the internal place; the caller must then return.
|
||||
func (s *Server) refuseInternalLifecycle(w http.ResponseWriter, r *http.Request, where string) bool {
|
||||
if s.settings == nil {
|
||||
return false
|
||||
}
|
||||
p := path.Clean(where)
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.IsInternal() && path.Clean(sp.Path) == p {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "err.web.belso_tarhely_eletciklus"), nil)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// The internal data place (felhom.eu architecture/16 §5, v0.318.0): bound and registered with no press, the default
|
||||
// drive when there is no other, never ejected, retired or formatted.
|
||||
|
||||
func internalRow(gp string, bound bool) agentapi.DiskInfo {
|
||||
return agentapi.DiskInfo{Name: "belso", Kind: agentapi.PlaceKindInternal, MountPath: "/mnt/belso", GuestPath: gp,
|
||||
BoundUnderParent: bound, Role: "user-data"}
|
||||
}
|
||||
|
||||
func TestInternalPlace_UnboundIsBoundNotYetRegistered(t *testing.T) {
|
||||
s := testServer(t)
|
||||
gp := filepath.Join(t.TempDir(), "belso")
|
||||
ag := &mockAgent{}
|
||||
s.ensureInternalPlace(context.Background(), ag, []agentapi.DiskInfo{internalRow(gp, false)})
|
||||
if len(ag.guestAttachCalls) != 1 || ag.guestAttachCalls[0] != "/mnt/belso" {
|
||||
t.Fatalf("want one guest bind of /mnt/belso, got %v", ag.guestAttachCalls)
|
||||
}
|
||||
if n := len(s.settings.GetStoragePaths()); n != 0 {
|
||||
t.Fatalf("an unbound place must not be registered yet, got %d path(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalPlace_BoundIsRegisteredAsTheDefaultDrive(t *testing.T) {
|
||||
s := testServer(t)
|
||||
gp := filepath.Join(t.TempDir(), "belso")
|
||||
ag := &mockAgent{}
|
||||
s.ensureInternalPlace(context.Background(), ag, []agentapi.DiskInfo{internalRow(gp, true)})
|
||||
paths := s.settings.GetStoragePaths()
|
||||
if len(paths) != 1 {
|
||||
t.Fatalf("want the internal place registered once, got %+v", paths)
|
||||
}
|
||||
sp := paths[0]
|
||||
if sp.Path != gp || !sp.IsInternal() || !sp.IsDefault || !sp.Schedulable || sp.Label == "" || strings.Contains(sp.Label, ".") {
|
||||
t.Fatalf("registered as: %+v", sp)
|
||||
}
|
||||
if len(ag.guestAttachCalls) != 0 {
|
||||
t.Fatalf("a bound place needs no new bind, got %v", ag.guestAttachCalls)
|
||||
}
|
||||
// Idempotent: the next tick changes nothing.
|
||||
s.ensureInternalPlace(context.Background(), ag, []agentapi.DiskInfo{internalRow(gp, true)})
|
||||
if n := len(s.settings.GetStoragePaths()); n != 1 {
|
||||
t.Fatalf("a second tick must not register it again, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalPlace_HouseholdDefaultStaysDefault(t *testing.T) {
|
||||
s := testServer(t)
|
||||
usb := filepath.Join(t.TempDir(), "hdd_1")
|
||||
if err := s.settings.AddStoragePath(settings.StoragePath{Path: usb, IsDefault: true, Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gp := filepath.Join(t.TempDir(), "belso")
|
||||
s.ensureInternalPlace(context.Background(), &mockAgent{}, []agentapi.DiskInfo{internalRow(gp, true)})
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Path == gp && sp.IsDefault {
|
||||
t.Fatalf("the household's own default drive must stay the default")
|
||||
}
|
||||
if sp.Path == usb && !sp.IsDefault {
|
||||
t.Fatalf("the USB default was taken away")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalPlace_OtherKindsAreNotTouched(t *testing.T) {
|
||||
s := testServer(t)
|
||||
ag := &mockAgent{}
|
||||
usb := agentapi.DiskInfo{Name: "hdd_1", Kind: "disk", MountPath: "/mnt/hdd_1", GuestPath: filepath.Join(t.TempDir(), "hdd_1"), BoundUnderParent: false}
|
||||
s.ensureInternalPlace(context.Background(), ag, []agentapi.DiskInfo{usb})
|
||||
if len(ag.guestAttachCalls) != 0 || len(s.settings.GetStoragePaths()) != 0 {
|
||||
t.Fatalf("a household drive is enrolled only by the household: attach=%v paths=%v", ag.guestAttachCalls, s.settings.GetStoragePaths())
|
||||
}
|
||||
}
|
||||
|
||||
// Eject, decommission and wipe of the internal place are refused in words. Positive control: a normal drive path is
|
||||
// not refused by this gate.
|
||||
func TestInternalPlace_LifecycleRefused(t *testing.T) {
|
||||
s := testServer(t)
|
||||
gp := filepath.Join(t.TempDir(), "belso")
|
||||
s.ensureInternalPlace(context.Background(), &mockAgent{}, []agentapi.DiskInfo{internalRow(gp, true)})
|
||||
r := httptest.NewRequest("POST", "/api/storage/eject", nil)
|
||||
w := httptest.NewRecorder()
|
||||
if !s.refuseInternalLifecycle(w, r, gp+"/") {
|
||||
t.Fatalf("the internal place must be refused")
|
||||
}
|
||||
if w.Code != 400 || !strings.Contains(w.Body.String(), "lemez") {
|
||||
t.Fatalf("refusal: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if s.refuseInternalLifecycle(httptest.NewRecorder(), r, "/mnt/felhom-drives/hdd_1") {
|
||||
t.Fatalf("positive control: a household drive is not refused by this gate")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Partitions as places (felhom.eu architecture/16 §6, v0.318.0): the free part of a disk whose other part is a
|
||||
// registered drive stays on offer; the whole disk never does — in the list AND on the server.
|
||||
|
||||
const partMounts = `/dev/mapper/pve-root /mnt/felhom-drives ext4 rw,relatime 0 0
|
||||
/dev/sdb1 /mnt/felhom-drives/hdd_1 ext4 rw,relatime 0 0
|
||||
/dev/mapper/pve-vm--9201--disk--1 /mnt/sys_drive ext4 rw,relatime 0 0
|
||||
`
|
||||
|
||||
func diskWithParts() agentapi.DiskCandidate {
|
||||
return agentapi.DiskCandidate{Device: "/dev/sdb", MountSource: "/dev/sdb1", FSType: "ext4", DataBearing: true,
|
||||
Partitions: []agentapi.CandidatePartition{
|
||||
{Device: "/dev/sdb1", Number: 1, FSType: "ext4", State: agentapi.PartInUse, MountedAt: "/mnt/felhom-drives/hdd_1"},
|
||||
{Device: "/dev/sdb2", Number: 2, State: agentapi.PartEmpty},
|
||||
{Device: "/dev/sdb3", Number: 3, FSType: "ext4", State: agentapi.PartFilesystem, Mountable: true, DurableID: "uuid:3333"},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestPartitions_FreePartsOfAnInUseDiskStayOffered(t *testing.T) {
|
||||
reg := map[string]bool{"/mnt/felhom-drives/hdd_1": true}
|
||||
got := dropRegisteredDrives(agentapi.CandidatesResult{Initialize: []agentapi.DiskCandidate{diskWithParts()}}, partMounts, reg)
|
||||
if len(got.Initialize) != 1 {
|
||||
t.Fatalf("the disk must stay listed for its free parts, got %+v", got.Initialize)
|
||||
}
|
||||
c := got.Initialize[0]
|
||||
if !c.WholeDiskInUse {
|
||||
t.Fatalf("the whole disk must be marked in use (no whole-disk action): %+v", c)
|
||||
}
|
||||
if len(c.Partitions) != 2 || c.Partitions[0].Device != "/dev/sdb2" || c.Partitions[1].Device != "/dev/sdb3" {
|
||||
t.Fatalf("only the free parts may be offered, got %+v", c.Partitions)
|
||||
}
|
||||
}
|
||||
|
||||
// R-542 unchanged: a disk with nothing free is still dropped.
|
||||
func TestPartitions_InUseDiskWithNoFreePartIsDropped(t *testing.T) {
|
||||
d := diskWithParts()
|
||||
d.Partitions = d.Partitions[:1]
|
||||
got := dropRegisteredDrives(agentapi.CandidatesResult{Initialize: []agentapi.DiskCandidate{d}}, partMounts, map[string]bool{"/mnt/felhom-drives/hdd_1": true})
|
||||
if len(got.Initialize) != 0 {
|
||||
t.Fatalf("a disk with no free part must not be offered, got %+v", got.Initialize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitions_ServerRefusesTheWholeDiskButNotTheFreePart(t *testing.T) {
|
||||
reg := map[string]bool{"/mnt/felhom-drives/hdd_1": true}
|
||||
for dev, want := range map[string]bool{"/dev/sdb": true, "/dev/sdb1": true, "/dev/sdb2": false, "/dev/sdc": false} {
|
||||
if got := deviceBacksRegistered(dev, partMounts, reg); got != want {
|
||||
t.Errorf("%s: backs a registered drive = %v, want %v", dev, got, want)
|
||||
}
|
||||
}
|
||||
if !deviceBacksRegistered("/dev/sdc", "", reg) {
|
||||
t.Errorf("an unreadable mount table must refuse (fail-safe)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitions_AFormattedPartsUUIDIsFound(t *testing.T) {
|
||||
ag := &mockAgent{candidates: agentapi.CandidatesResult{Initialize: []agentapi.DiskCandidate{diskWithParts()}}}
|
||||
if got := resolveEnrollUUID(context.Background(), ag, "/dev/sdb3"); got != "3333" {
|
||||
t.Fatalf("the part's new filesystem must be found for the mount, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The handler asks before it starts anything: the whole disk under a registered part → 409, never a format job.
|
||||
// Positive control: the free part passes the guard and reaches the agent step (503 here: no agent configured).
|
||||
func TestPartitions_InitHandlerRefusesTheWholeDisk(t *testing.T) {
|
||||
s := testServer(t)
|
||||
s.mountsFn = func() string { return partMounts }
|
||||
if err := s.settings.AddStoragePath(settingsPath("/mnt/felhom-drives/hdd_1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := func(dev string) int {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/api/storage/init", strings.NewReader(`{"device":"`+dev+`","fstype":"ext4","mount_name":"films"}`))
|
||||
s.handleStorageInit(w, r)
|
||||
return w.Code
|
||||
}
|
||||
if c := post("/dev/sdb"); c != http.StatusConflict {
|
||||
t.Fatalf("the whole disk under a registered part: want 409, got %d", c)
|
||||
}
|
||||
if c := post("/dev/sdb2"); c == http.StatusConflict {
|
||||
t.Fatalf("positive control: the free part must pass the guard, got %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
func settingsPath(p string) settings.StoragePath {
|
||||
return settings.StoragePath{Path: p, Schedulable: true}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
|
||||
// R-1082 (operator ruling 2026-10-16 „hide it now"; felhom.eu architecture/16 §11): the drives page never offers the
|
||||
// guest's own data volume to attach — /mnt/sys_drive rides in every whole-box copy. The raw filter
|
||||
// (mountedUnregisteredStores) still sees it; what the page offers (attachableStores) does not.
|
||||
|
||||
const r1082Mounts = demoHPMounts +
|
||||
"/dev/mapper/pve-vm--9201--disk--1 /var/lib/felhom ext4 rw,relatime 0 0\n" +
|
||||
"/dev/mapper/pve-vm--9201--disk--1 /mnt/films ext4 rw,relatime 0 0\n" + // a second bind of the same volume
|
||||
"/dev/sdd1 /mnt/usb_films ext4 rw,relatime 0 0\n" // a real USB drive with a filesystem
|
||||
|
||||
func TestR1082_SysDriveAndItsAliasesAreNotOffered(t *testing.T) {
|
||||
got := withoutSystemVolume(mountedUnregisteredStores(r1082Mounts, map[string]bool{}), r1082Mounts, "/mnt/sys_drive")
|
||||
if len(got) != 1 || got[0].Path != "/mnt/usb_films" {
|
||||
t.Fatalf("want only the USB drive offered (positive control), got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR1082_EmptyConfigFallsBackToHiding(t *testing.T) {
|
||||
got := withoutSystemVolume(mountedUnregisteredStores(demoHPMounts, map[string]bool{}), demoHPMounts, "")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("with no configured system data path, sys_drive must still be hidden, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The page's own list (the derivation the candidates endpoint and the restore page share) hides it.
|
||||
func TestR1082_AttachableStoresHidesSysDrive(t *testing.T) {
|
||||
s := testServer(t)
|
||||
s.cfg.Paths.SystemDataPath = "/mnt/sys_drive"
|
||||
got := s.attachableStoresFrom(r1082Mounts)
|
||||
if len(got) != 1 || got[0].Path != "/mnt/usb_films" {
|
||||
t.Fatalf("the page must offer only the USB drive, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-1084 (v0.318.0, felhom.eu architecture/16 §4): the whole-box backup offer promises survival of a DISK failure only
|
||||
// for a drive the agent proves is another disk than the system data. Seen on Tester 1 (a VM): the promise stood next to
|
||||
// build A's „cannot check" notice.
|
||||
|
||||
func offerHarness(t *testing.T, rel map[string]offerRelation, paths ...string) *Server {
|
||||
t.Helper()
|
||||
var disks []agentapi.DiskInfo
|
||||
for _, p := range paths {
|
||||
disks = append(disks, agentapi.DiskInfo{Name: p, GuestPath: p, MountPath: "/mnt/" + strings.TrimPrefix(p, "/mnt/felhom-drives/"), Role: "user-data"})
|
||||
}
|
||||
s := absentHarness(t, "local", disks, "")
|
||||
for _, p := range paths {
|
||||
if err := s.settings.AddStoragePath(settings.StoragePath{Path: p, Label: "Meghajtó " + p}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s.sameDiskFn = func(_ context.Context, drive, sysData string) offerRelation {
|
||||
if sysData != "/mnt/sys_drive" {
|
||||
t.Errorf("the offer must compare against the system data path, got %q", sysData)
|
||||
}
|
||||
return rel[drive]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
const drvA, drvB = "/mnt/felhom-drives/hdd_1", "/mnt/felhom-drives/hdd_2"
|
||||
|
||||
func TestR1084_ProvenDifferentDiskKeepsThePromise(t *testing.T) {
|
||||
s := offerHarness(t, map[string]offerRelation{drvA: offerDifferent}, drvA)
|
||||
v := s.backupTargetView(context.Background(), "hu")
|
||||
if v == nil || v.OfferPath != drvA || v.OfferText != huText(t, backupTargetOfferKey) {
|
||||
t.Fatalf("a proven different disk: want today's offer, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR1084_UnprovenDiskIsOfferedWithoutThePromise(t *testing.T) {
|
||||
s := offerHarness(t, map[string]offerRelation{drvA: offerUnknown}, drvA)
|
||||
v := s.backupTargetView(context.Background(), "hu")
|
||||
if v == nil || v.OfferPath != drvA {
|
||||
t.Fatalf("an unproven disk is still offered, got %+v", v)
|
||||
}
|
||||
if strings.Contains(v.OfferText, "így egy lemezhiba után is vissza tudod") || !strings.Contains(v.OfferText, "nem tudja ellenőrizni") {
|
||||
t.Fatalf("an unproven disk must not carry the disk-failure promise; got %q", v.OfferText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR1084_SameDiskIsNeverOffered(t *testing.T) {
|
||||
s := offerHarness(t, map[string]offerRelation{drvA: offerSame}, drvA)
|
||||
if v := s.backupTargetView(context.Background(), "hu"); v == nil || v.OfferPath != "" {
|
||||
t.Fatalf("a drive on the system disk must not be offered as the backup place, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR1084_AProvenDiskWinsOverAnUnprovenOne(t *testing.T) {
|
||||
s := offerHarness(t, map[string]offerRelation{drvA: offerUnknown, drvB: offerDifferent}, drvA, drvB)
|
||||
if v := s.backupTargetView(context.Background(), "hu"); v == nil || v.OfferPath != drvB || v.OfferText != huText(t, backupTargetOfferKey) {
|
||||
t.Fatalf("the proven different disk must be offered first, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// An agent older than 0.157.0 cannot judge: today's offer stays (build A's rule for an older agent).
|
||||
func TestR1084_OlderAgentKeepsTodaysOffer(t *testing.T) {
|
||||
s := offerHarness(t, map[string]offerRelation{drvA: offerLegacy}, drvA)
|
||||
if v := s.backupTargetView(context.Background(), "hu"); v == nil || v.OfferPath != drvA || v.OfferText != huText(t, backupTargetOfferKey) {
|
||||
t.Fatalf("an older agent: want today's offer, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// The internal place is never the whole-box backup place (it is the system disk).
|
||||
func TestR1084_InternalPlaceIsNeverOffered(t *testing.T) {
|
||||
s := absentHarness(t, "local", []agentapi.DiskInfo{{Name: "belso", GuestPath: "/mnt/felhom-drives/belso", MountPath: "/mnt/belso", Role: "user-data", Kind: agentapi.PlaceKindInternal}}, "")
|
||||
if err := s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/belso", Kind: settings.StorageKindInternal}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.sameDiskFn = func(context.Context, string, string) offerRelation { return offerLegacy } // even an old judge
|
||||
if v := s.backupTargetView(context.Background(), "hu"); v == nil || v.OfferPath != "" {
|
||||
t.Fatalf("the internal place must not be offered, got %+v", v)
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,11 @@ type Server struct {
|
||||
// pure copy helper. Without it the resolver's own branching had no test at all, which is how the
|
||||
// configured-but-absent case reached production saying the wrong thing.
|
||||
tiersFn func(context.Context) (agentapi.TiersResponse, error)
|
||||
// sameDiskFn (R-1084, v0.318.0) is the test seam for the backup-target offer's same-disk question
|
||||
// (nil → the real agent client, POST /disks/same-disk).
|
||||
sameDiskFn func(ctx context.Context, drive, systemData string) offerRelation
|
||||
// mountsFn (v0.318.0) is the test seam for the guest's mount table (nil → /proc/mounts).
|
||||
mountsFn func() string
|
||||
|
||||
// App-email SMTP shim lifecycle (optional — nil when no hub is configured or the kill-switch is
|
||||
// off). The global app-email settings toggle calls Apply() so the shim starts/stops at runtime.
|
||||
|
||||
@@ -86,6 +86,14 @@ func resolveEnrollUUID(ctx context.Context, agent diskAgent, device string) stri
|
||||
return rest
|
||||
}
|
||||
}
|
||||
// `16` §6 (agent ≥ 0.158.0): a formatted PART reports its new filesystem under the disk's partitions.
|
||||
for _, p := range c.Partitions {
|
||||
if p.Device == device {
|
||||
if rest, ok := strings.CutPrefix(p.DurableID, "uuid:"); ok {
|
||||
return rest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -503,6 +511,9 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
|
||||
if s.refuseNetworkLifecycle(w, req.Where) {
|
||||
return
|
||||
}
|
||||
if s.refuseInternalLifecycle(w, r, req.Where) { // v0.318.0 `16` §5
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Mode {
|
||||
case "migrate":
|
||||
@@ -651,6 +662,14 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.device_required"), nil)
|
||||
return
|
||||
}
|
||||
// `16` §6 (v0.318.0): never format a device that backs a REGISTERED drive — the whole disk under a registered part
|
||||
// included. The list (dropRegisteredDrives) already hides it; this is the server-side half, so a stale page or a
|
||||
// direct call cannot reach it. A FREE part next to the registered one is allowed (the agent and its root wrapper
|
||||
// re-check, as root).
|
||||
if deviceBacksRegistered(req.Device, s.mountTable(), s.registeredStoragePaths()) {
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "err.web.meghajto_hasznalatban_formazas"), nil)
|
||||
return
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
@@ -764,6 +783,9 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
|
||||
if s.refuseNetworkLifecycle(w, req.Where) {
|
||||
return
|
||||
}
|
||||
if s.refuseInternalLifecycle(w, r, req.Where) { // v0.318.0 `16` §5
|
||||
return
|
||||
}
|
||||
// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
|
||||
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.name_mismatch"), nil)
|
||||
@@ -982,6 +1004,9 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
|
||||
if s.refuseNetworkLifecycle(w, req.Where) {
|
||||
return
|
||||
}
|
||||
if s.refuseInternalLifecycle(w, r, req.Where) { // v0.318.0 `16` §5
|
||||
return
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .IsInternal}}
|
||||
<div class="form-hint">{{T "storage.belso_tarhely_magyarazat"}}</div>
|
||||
{{end}}
|
||||
{{if .FSInfo}}
|
||||
<div class="storage-path-fsinfo mono form-hint">
|
||||
{{.FSInfo.FSType}} · {{.FSInfo.Device}}{{if .FSInfo.Model}} · {{.FSInfo.Model}}{{end}}
|
||||
@@ -138,7 +141,7 @@
|
||||
<button type="submit" class="btn btn-xs btn-outline" title="{{T "storage.a_meghajto_ujra_valaszthato_az"}}">{{T "storage.uj_telepitesek_engedelyezese"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .IsUSB}}
|
||||
{{if and .IsUSB (not .IsInternal)}}
|
||||
<button class="btn btn-xs btn-danger-outline" onclick="storageDisconnect('{{.Path}}', '{{.Label}}', {{.AppCount}})" title="{{T "storage.az_itt_tarolt_alkalmazasok_leallnak"}}">{{T "storage.biztonsagos_levalasztas"}}</button>
|
||||
{{end}}
|
||||
{{/* List-removal is the undo of a MANUAL add only. Wizard-enrolled drives
|
||||
@@ -163,7 +166,9 @@
|
||||
<button class="btn btn-xs btn-outline" onclick="storageMigrateAll('{{.Path}}','{{.Label}}')">{{T "storage.osszes_adat_athelyezese"}}</button>
|
||||
</span>
|
||||
{{end}}
|
||||
{{if not .IsInternal}}
|
||||
<button class="btn btn-xs btn-danger-outline" onclick="storageDecommission('{{.Path}}','{{.Label}}',{{.AppCount}})" title="{{T "storage.a_meghajto_vegleges_kivonasa_a"}}">{{T "storage.vegleges_leszereles"}}</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -303,6 +308,8 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
|
||||
function actions(d, registered, hasSafeDisconnect){
|
||||
// Destructive controls ONLY for user-data drives that are mounted under /mnt. System/backup get none.
|
||||
if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return '';
|
||||
// The internal data place (`16` §5) is part of the system disk: no eject, no format (the agent refuses both too).
|
||||
if(d.kind==='internal') return '';
|
||||
// A user-data drive that ALSO backs the whole-system backup (agent `backup_target`): the agent refuses its eject
|
||||
// (403) and a data-bearing format needs a signed job, so neither button is offered (R-298 design, 2026-10-08).
|
||||
if(d.backup_target){
|
||||
|
||||
@@ -71,6 +71,20 @@ async function loadDisks(){
|
||||
var cands = (j.data&&j.data.attach)||[];
|
||||
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">{{T "storage_attach.nincs_csatolhato_meghajto_a_csatolashoz"}}</p>'; return; }
|
||||
var html='<div class="drive-list">';
|
||||
// `16` §6 (agent ≥ 0.158.0): a disk that lists its parts offers each usable part (an ext4/xfs filesystem not yet
|
||||
// a drive) as its own card, instead of the disk's first filesystem.
|
||||
var flat=[];
|
||||
cands.forEach(function(d){
|
||||
var usable=(d.partitions||[]).filter(function(p){ return p.state==='filesystem' && p.mountable; });
|
||||
if(!d.already_mounted && d.partitions && d.partitions.length){
|
||||
usable.forEach(function(p){ flat.push({device:p.device, mount_source:p.device, fstype:p.fstype, size_bytes:p.size_bytes,
|
||||
model:(d.model?d.model:d.device)+' — '+'{{T "storage_wizard.resz_n"}}'.replace('{n}', p.number||'?')}); });
|
||||
return;
|
||||
}
|
||||
flat.push(d);
|
||||
});
|
||||
cands=flat;
|
||||
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">{{T "storage_attach.nincs_csatolhato_meghajto_a_csatolashoz"}}</p>'; return; }
|
||||
cands.forEach(function(d,i){
|
||||
var title = d.model ? esc(d.model) : esc(d.device);
|
||||
var parts = [esc(d.device)];
|
||||
|
||||
@@ -82,16 +82,26 @@ async function loadDisks(){
|
||||
var cands = (j.data&&j.data.initialize)||[];
|
||||
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">{{T "storage_init.nincs_elerheto_meghajto_az_inicializalas"}}</p>'; return; }
|
||||
var html='<div class="drive-list">';
|
||||
cands.forEach(function(d,i){
|
||||
var n=0;
|
||||
function card(dev, title, sub, db){
|
||||
var i=n++;
|
||||
return '<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
|
||||
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(dev)+'" data-db="'+(db?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
|
||||
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
|
||||
+'<div class="drive-badges">'+dataBadge({data_bearing:db})+'</div></div></label>';
|
||||
}
|
||||
cands.forEach(function(d){
|
||||
var title = d.model ? esc(d.model) : esc(d.device);
|
||||
var parts = [esc(d.device)];
|
||||
if(d.size_bytes) parts.push(fmtSize(d.size_bytes));
|
||||
if(d.fstype) parts.push('{{T "storage_init.jelenlegi"}} '+esc(d.fstype));
|
||||
var sub = parts.join(' · ');
|
||||
html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
|
||||
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(d.device)+'" data-db="'+(d.data_bearing?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
|
||||
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
|
||||
+'<div class="drive-badges">'+dataBadge(d)+'</div></div></label>';
|
||||
// `16` §6: a disk whose other part is already a drive offers only its free parts, never the whole disk.
|
||||
if(!d.whole_disk_in_use) html+=card(d.device, title, parts.join(' · '), d.data_bearing);
|
||||
(d.partitions||[]).forEach(function(p){
|
||||
if(p.state!=='empty' && p.state!=='filesystem') return;
|
||||
var ps=[esc(p.device)]; if(p.size_bytes) ps.push(fmtSize(p.size_bytes)); if(p.fstype) ps.push('{{T "storage_init.jelenlegi"}} '+esc(p.fstype));
|
||||
html+=card(p.device, title+' — '+'{{T "storage_wizard.resz_n"}}'.replace('{n}', p.number||'?'), ps.join(' · '), p.state==='filesystem');
|
||||
});
|
||||
});
|
||||
html+='</div>';
|
||||
document.getElementById('disk-list').innerHTML=html;
|
||||
|
||||
@@ -243,6 +243,20 @@ async function loadDisks(){
|
||||
var cands = (j.data&&j.data.attach)||[];
|
||||
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs csatolható meghajtó — a csatoláshoz olyan (még nem használt) meghajtó kell, amelyen már van fájlrendszer. Új, üres meghajtóhoz használd az „Inicializálás” lehetőséget.</p>'; return; }
|
||||
var html='<div class="drive-list">';
|
||||
|
||||
|
||||
var flat=[];
|
||||
cands.forEach(function(d){
|
||||
var usable=(d.partitions||[]).filter(function(p){ return p.state==='filesystem' && p.mountable; });
|
||||
if(!d.already_mounted && d.partitions && d.partitions.length){
|
||||
usable.forEach(function(p){ flat.push({device:p.device, mount_source:p.device, fstype:p.fstype, size_bytes:p.size_bytes,
|
||||
model:(d.model?d.model:d.device)+' — '+'{n}. rész'.replace('{n}', p.number||'?')}); });
|
||||
return;
|
||||
}
|
||||
flat.push(d);
|
||||
});
|
||||
cands=flat;
|
||||
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs csatolható meghajtó — a csatoláshoz olyan (még nem használt) meghajtó kell, amelyen már van fájlrendszer. Új, üres meghajtóhoz használd az „Inicializálás” lehetőséget.</p>'; return; }
|
||||
cands.forEach(function(d,i){
|
||||
var title = d.model ? esc(d.model) : esc(d.device);
|
||||
var parts = [esc(d.device)];
|
||||
|
||||
@@ -388,6 +388,8 @@ window.__registeredPaths=[];
|
||||
|
||||
if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return '';
|
||||
|
||||
if(d.kind==='internal') return '';
|
||||
|
||||
|
||||
if(d.backup_target){
|
||||
return registered[regKey(d)] ? '' : '<div class="drive-actions"><button class="btn btn-xs btn-primary" onclick="registerDrive(\''+esc(d.mount_path)+'\')">Regisztrálás</button></div>';
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="storage-path-fsinfo mono form-hint">
|
||||
ext4 · /dev/sdb1 · WD Red
|
||||
</div>
|
||||
@@ -403,12 +404,14 @@
|
||||
<span class="migrate-inline" style="display:inline-flex;gap:.35rem;align-items:center">
|
||||
<select id="migrate-target-/mnt/hdd1" class="btn btn-xs btn-outline">
|
||||
<option value="">Áthelyezés ide…</option>
|
||||
<option value="/mnt/hdd3">HDD három (/mnt/hdd3)</option>
|
||||
<option value="/mnt/hdd3">HDD három (/mnt/hdd3)</option><option value="/mnt/felhom-drives/belso">Belső tárhely (/mnt/felhom-drives/belso)</option>
|
||||
</select>
|
||||
<button class="btn btn-xs btn-outline" onclick="storageMigrateAll('\/mnt\/hdd1','HDD egy')">Összes adat áthelyezése</button>
|
||||
</span>
|
||||
|
||||
|
||||
<button class="btn btn-xs btn-danger-outline" onclick="storageDecommission('\/mnt\/hdd1','HDD egy', 1 )" title="A meghajtó végleges kivonása a rendszerből. Ha céltárolót választ, előbb minden adat átkerül oda; áthelyezés nélkül az adatok a meghajtón maradnak.">Végleges leszerelés</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -446,6 +449,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="storage-path-fsinfo mono form-hint">
|
||||
ext4 · /dev/sdc1
|
||||
</div>
|
||||
@@ -485,7 +489,9 @@
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
<button class="btn btn-xs btn-danger-outline" onclick="storageDecommission('\/mnt\/hdd2','HDD kettő', 0 )" title="A meghajtó végleges kivonása a rendszerből. Ha céltárolót választ, előbb minden adat átkerül oda; áthelyezés nélkül az adatok a meghajtón maradnak.">Végleges leszerelés</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -524,6 +530,7 @@
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="storage-path-meta">
|
||||
|
||||
<span class="form-hint">Nincs alkalmazás ezen a tárolón</span>
|
||||
@@ -551,7 +558,67 @@
|
||||
|
||||
|
||||
|
||||
|
||||
<button class="btn btn-xs btn-danger-outline" onclick="storageDecommission('\/mnt\/hdd3','HDD három', 0 )" title="A meghajtó végleges kivonása a rendszerből. Ha céltárolót választ, előbb minden adat átkerül oda; áthelyezés nélkül az adatok a meghajtón maradnak.">Végleges leszerelés</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="storage-path-item">
|
||||
<div class="storage-path-header">
|
||||
<div class="storage-path-info">
|
||||
<div class="storage-path-label-wrap" id="label-wrap-/mnt/felhom-drives/belso">
|
||||
<span class="storage-path-label" id="label-display-/mnt/felhom-drives/belso">Belső tárhely</span>
|
||||
<button class="btn btn-xs btn-ghost" onclick="editStorageLabel('\/mnt\/felhom-drives\/belso', 'Belső tárhely')" title="Átnevezés"><svg class="ico ico-sm"><use href="#i-pencil"/></svg></button>
|
||||
</div>
|
||||
<span class="storage-path-path mono">/mnt/felhom-drives/belso</span>
|
||||
</div>
|
||||
<div class="storage-path-badges">
|
||||
|
||||
|
||||
<span class="tag tag-run"><span class="dot"></span>Aktív</span>
|
||||
<span class="badge" style="background:rgba(255,165,0,0.15);color:var(--warn)">USB</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="storage-path-details">
|
||||
|
||||
|
||||
<div class="form-hint">A gép saját lemezén van, de nem része a teljes rendszermentésnek. Egy lemezhiba ellen egy másik lemez vagy a távoli mentés véd.</div>
|
||||
|
||||
|
||||
|
||||
<div class="storage-path-meta">
|
||||
|
||||
<span class="form-hint">Nincs alkalmazás ezen a tárolón</span>
|
||||
|
||||
</div>
|
||||
<div class="drive-agent-extra" id="agent-extra-/mnt/felhom-drives/belso"></div>
|
||||
</div>
|
||||
<div class="storage-path-actions">
|
||||
|
||||
<form method="POST" action="/settings/storage/default" style="display:inline">
|
||||
|
||||
<input type="hidden" name="storage_path" value="/mnt/felhom-drives/belso">
|
||||
<button type="submit" class="btn btn-xs btn-outline">Legyen alapértelmezett</button>
|
||||
</form>
|
||||
|
||||
|
||||
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
|
||||
|
||||
<input type="hidden" name="storage_path" value="/mnt/felhom-drives/belso">
|
||||
<input type="hidden" name="schedulable" value="false">
|
||||
<button type="submit" class="btn btn-xs btn-outline" title="A meghajtó kimarad az új alkalmazás-telepítésekből. A rajta lévő alkalmazások változatlanul futnak.">Új telepítések letiltása</button>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -629,7 +696,7 @@
|
||||
<div id="confirm-root"></div>
|
||||
<div id="dialog-root"></div>
|
||||
<script>
|
||||
window.__registeredPaths=["\/mnt\/usb1","\/mnt\/old","\/mnt\/hdd1","\/mnt\/hdd2","\/mnt\/hdd3",];
|
||||
window.__registeredPaths=["\/mnt\/usb1","\/mnt\/old","\/mnt\/hdd1","\/mnt\/hdd2","\/mnt\/hdd3","\/mnt\/felhom-drives\/belso",];
|
||||
(function(){
|
||||
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
||||
function hum(b){ if(!b||b<=0) return ''; var u=['B','KB','MB','GB','TB'],i=0,v=b; while(v>=1024&&i<u.length-1){v/=1024;i++;} return (v>=10||i===0?Math.round(v):fhNum(v, 1))+' '+u[i]; }
|
||||
@@ -688,6 +755,8 @@ window.__registeredPaths=["\/mnt\/usb1","\/mnt\/old","\/mnt\/hdd1","\/mnt\/hdd2"
|
||||
|
||||
if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return '';
|
||||
|
||||
if(d.kind==='internal') return '';
|
||||
|
||||
|
||||
if(d.backup_target){
|
||||
return registered[regKey(d)] ? '' : '<div class="drive-actions"><button class="btn btn-xs btn-primary" onclick="registerDrive(\''+esc(d.mount_path)+'\')">Regisztrálás</button></div>';
|
||||
|
||||
@@ -256,16 +256,26 @@ async function loadDisks(){
|
||||
var cands = (j.data&&j.data.initialize)||[];
|
||||
if(cands.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs elérhető meghajtó az inicializáláshoz. Csatlakoztass egy új adathordozót — a rendszer-, a biztonsági mentés- és a már használatban lévő meghajtók itt nem jelennek meg.</p>'; return; }
|
||||
var html='<div class="drive-list">';
|
||||
cands.forEach(function(d,i){
|
||||
var n=0;
|
||||
function card(dev, title, sub, db){
|
||||
var i=n++;
|
||||
return '<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
|
||||
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(dev)+'" data-db="'+(db?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
|
||||
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
|
||||
+'<div class="drive-badges">'+dataBadge({data_bearing:db})+'</div></div></label>';
|
||||
}
|
||||
cands.forEach(function(d){
|
||||
var title = d.model ? esc(d.model) : esc(d.device);
|
||||
var parts = [esc(d.device)];
|
||||
if(d.size_bytes) parts.push(fmtSize(d.size_bytes));
|
||||
if(d.fstype) parts.push('jelenlegi: '+esc(d.fstype));
|
||||
var sub = parts.join(' · ');
|
||||
html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
|
||||
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(d.device)+'" data-db="'+(d.data_bearing?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
|
||||
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
|
||||
+'<div class="drive-badges">'+dataBadge(d)+'</div></div></label>';
|
||||
|
||||
if(!d.whole_disk_in_use) html+=card(d.device, title, parts.join(' · '), d.data_bearing);
|
||||
(d.partitions||[]).forEach(function(p){
|
||||
if(p.state!=='empty' && p.state!=='filesystem') return;
|
||||
var ps=[esc(p.device)]; if(p.size_bytes) ps.push(fmtSize(p.size_bytes)); if(p.fstype) ps.push('jelenlegi: '+esc(p.fstype));
|
||||
html+=card(p.device, title+' — '+'{n}. rész'.replace('{n}', p.number||'?'), ps.join(' · '), p.state==='filesystem');
|
||||
});
|
||||
});
|
||||
html+='</div>';
|
||||
document.getElementById('disk-list').innerHTML=html;
|
||||
|
||||
Reference in New Issue
Block a user