B2b: decommission orchestration + missing-storage indicator + re-enroll fix (v0.65.0)
agentapi.Decommission + handleStorageDecommission (migrate-all-or-none, Change 2): migrate-then-decommission via the migration done-hook, or decommission-anyway (stop apps, keep HDD_PATH). 'Hiányzó tárhely' badge on dashboard/stacks/app card when an app's drive is decommissioned/disconnected/absent. Change 4: registerStoragePath clears the decommissioned marker on re-enroll (ClearDecommissioned had no callers). Non-hollow tests incl. mutation-proven Change-4 companion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,48 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.65.0 — data migration + self-serve decommission (B1+B2) (2026-06-14)
|
||||||
|
|
||||||
|
Customer-self-serve storage **migration** (move app data between drives) and **decommission** (retire
|
||||||
|
a drive), implemented trunk-based with the locked spike design
|
||||||
|
(`felhom.eu/documentation/audits/SPIKE-decommission-migration-2026-06-14.md`). Pairs with agent
|
||||||
|
v0.32.0 (the self-serve `/disks/decommission` endpoint + intent-aware re-assert). Built + deployed to
|
||||||
|
demo guest 9201. **Live decommission/migration of real data is NOT yet validated — that is the
|
||||||
|
supervised B3 session.**
|
||||||
|
|
||||||
|
- **B1 — migration engine** (`internal/stacks/migrate.go`). In-process over the controller's
|
||||||
|
`/mnt:/mnt:rslave` RW mount; crash-safe + resumable via a single journal (`<dataDir>/migration.json`).
|
||||||
|
Two entry points share one pipeline: `MigrateAll` (whole namespace — every app + a conflict-merge
|
||||||
|
walk for non-app/customer content) and `MigrateApp` (one app subtree; handles drive→drive AND
|
||||||
|
SSD→drive). Pipeline: validate → stop → copy (`rsync -a --checksum`, additive, NO `--delete`) → verify
|
||||||
|
(`rsync -ani --checksum`, zero pending) → flip+redeploy (`RedeployFromEnv`, one idempotent unit) →
|
||||||
|
cleanup. **CLEANUP is the only destructive step and is gated on every unit verified AND every app
|
||||||
|
redeployed.** Conflict-merge: skip-identical (checksum vs the target file AND its `(N)` siblings),
|
||||||
|
rename-on-differ to the lowest-free `<base>(N)<ext>`, never overwrite; idempotent (no `(1)(1)`).
|
||||||
|
Single-flight; **mutual exclusion with the backup orchestrator** (Change 3 — migration refuses while a
|
||||||
|
backup runs; the scheduled DB-dump/Tier-2 skip while a migration runs).
|
||||||
|
- **B1 UI** — `POST /api/storage/migrate` (whole-namespace), `POST /api/storage/migrate-app` (per-app),
|
||||||
|
`GET /api/storage/migrate/status` (poll). The greyed migrate-all `<span>` in settings.html is now a
|
||||||
|
real target-select + button; app_info.html gains a per-app "Áthelyezés másik tárhelyre" control; both
|
||||||
|
share a Hungarian progress panel.
|
||||||
|
- **B2b — decommission orchestration** (`handleStorageDecommission`, `POST /api/storage/decommission`).
|
||||||
|
Two choices, no partial (Change 2): **migrate-all-then-decommission** (runs `MigrateAll`; the
|
||||||
|
migration done-hook soft-marks the source + calls the agent once every app has moved) or
|
||||||
|
**decommission-anyway** (type-to-confirm; stops the apps but KEEPS their `HDD_PATH` so they show
|
||||||
|
"missing storage"). `agentapi.Decommission` added; both branches end at `SetDecommissioned` (soft
|
||||||
|
marker retained — blocks A1 resurrection) + agent `Decommission`.
|
||||||
|
- **"Hiányzó tárhely" indicator** — a deployed app whose `HDD_PATH` resolves to a decommissioned/
|
||||||
|
disconnected/absent registry path now shows a distinct warning badge on the dashboard, stacks page,
|
||||||
|
and app card (label via `GetStorageLabel`); persists until re-enroll or migrate.
|
||||||
|
- **Change 4 — re-enroll clears the marker.** `registerStoragePath` now un-retires a re-plugged
|
||||||
|
decommissioned drive (`ClearDecommissioned` + restore `Schedulable`) — previously `AddStoragePath`
|
||||||
|
deduped the re-register into a no-op and the soft marker (and the apps' missing-storage badge) would
|
||||||
|
persist forever. (`ClearDecommissioned` had zero callers before this.)
|
||||||
|
- Non-hollow tests across `internal/stacks` (engine: collision-refuse, merge dedup/idempotency,
|
||||||
|
cleanup-only-after-redeploy, verify-catches-corruption, resume, single-flight, SSD→drive, backup
|
||||||
|
exclusion), `internal/backup` (scheduled backup skipped while migrating), and `internal/web`
|
||||||
|
(finalize soft-mark+agent, re-enroll clears marker, missing-storage label). Companions for the
|
||||||
|
collision guard, cleanup gate, and Change-4 clearing were mutation-proven to fail on the pre-fix code.
|
||||||
|
|
||||||
### v0.64.0 — storage-lifecycle cleanups (2026-06-14)
|
### v0.64.0 — storage-lifecycle cleanups (2026-06-14)
|
||||||
|
|
||||||
Two settings-layer cleanups from the F9 storage-registration diagnosis
|
Two settings-layer cleanups from the F9 storage-registration diagnosis
|
||||||
|
|||||||
@@ -231,7 +231,8 @@ func main() {
|
|||||||
if backupMgr != nil {
|
if backupMgr != nil {
|
||||||
backupMgr.SetMigrationRunningCheck(stackMgr.IsMigrating)
|
backupMgr.SetMigrationRunningCheck(stackMgr.IsMigrating)
|
||||||
}
|
}
|
||||||
stackMgr.RecoverMigration(ctx)
|
// RecoverMigration is started AFTER the web server's done-hook is wired (below), so a resumed
|
||||||
|
// decommission-migration still finalizes the source decommission on completion.
|
||||||
|
|
||||||
// --- Initialize alert manager ---
|
// --- Initialize alert manager ---
|
||||||
alertMgr := web.NewAlertManager(logger)
|
alertMgr := web.NewAlertManager(logger)
|
||||||
@@ -637,6 +638,10 @@ func main() {
|
|||||||
|
|
||||||
// --- Initialize web server ---
|
// --- Initialize web server ---
|
||||||
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, alertMgr, notifier, updater, logger, Version)
|
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, alertMgr, notifier, updater, logger, Version)
|
||||||
|
// Migration done-hook: a decommission-initiated migration finalizes the source decommission on
|
||||||
|
// success (soft-mark + agent). Wire it before RecoverMigration so a resumed one still finalizes.
|
||||||
|
stackMgr.SetMigrationDoneHook(webServer.OnMigrationDone)
|
||||||
|
stackMgr.RecoverMigration(ctx)
|
||||||
webServer.SetEncryptionKey(encKey)
|
webServer.SetEncryptionKey(encKey)
|
||||||
webServer.SetAppExporter(appExporter)
|
webServer.SetAppExporter(appExporter)
|
||||||
webServer.SetIntegrationManager(integrationMgr)
|
webServer.SetIntegrationManager(integrationMgr)
|
||||||
|
|||||||
@@ -134,10 +134,10 @@ type StatusResponse struct {
|
|||||||
// BackupRecord mirrors the agent's hub.Backup — one whole-guest vzdump/PBS backup result. The
|
// BackupRecord mirrors the agent's hub.Backup — one whole-guest vzdump/PBS backup result. The
|
||||||
// controller renders it read-only (it does NOT own whole-guest backup; the agent does).
|
// controller renders it read-only (it does NOT own whole-guest backup; the agent does).
|
||||||
type BackupRecord struct {
|
type BackupRecord struct {
|
||||||
TargetID string `json:"target_id"` // backup storage name (e.g. "local", "felhom-pbs")
|
TargetID string `json:"target_id"` // backup storage name (e.g. "local", "felhom-pbs")
|
||||||
VMID int `json:"vmid"`
|
VMID int `json:"vmid"`
|
||||||
Archive string `json:"archive"` // produced vzdump volid (e.g. "local:backup/vzdump-lxc-…")
|
Archive string `json:"archive"` // produced vzdump volid (e.g. "local:backup/vzdump-lxc-…")
|
||||||
Mode string `json:"mode"` // snapshot | stop
|
Mode string `json:"mode"` // snapshot | stop
|
||||||
CrashConsistent bool `json:"crash_consistent"`
|
CrashConsistent bool `json:"crash_consistent"`
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -368,6 +368,28 @@ func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, erro
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DecommissionResult mirrors POST /disks/decommission.
|
||||||
|
type DecommissionResult struct {
|
||||||
|
VMID int `json:"vmid"`
|
||||||
|
Decommissioned string `json:"decommissioned"`
|
||||||
|
DependentGuests []int `json:"dependent_guests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decommission permanently removes a user-data drive (self-serve, non-destructive — the agent records
|
||||||
|
// IntentDecommissioned, prunes the bind record, and unmounts; it NEVER formats). Data stays on the
|
||||||
|
// drive. The agent role-gates to user-data and refuses a system/backup mount regardless.
|
||||||
|
func (c *Client) Decommission(ctx context.Context, where string) (DecommissionResult, error) {
|
||||||
|
var out DecommissionResult
|
||||||
|
body, err := c.post(ctx, "/disks/decommission", map[string]string{"where": where})
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return out, fmt.Errorf("agentapi: decode /disks/decommission: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FormatDisk asks the agent to format a device. The AGENT inspects the device and tiers it by ROLE
|
// FormatDisk asks the agent to format a device. The AGENT inspects the device and tiers it by ROLE
|
||||||
// (its own classification, never the controller's claim):
|
// (its own classification, never the controller's claim):
|
||||||
// - blank device → formatted.
|
// - blank device → formatted.
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ type ContainerState string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
StateRunning ContainerState = "running"
|
StateRunning ContainerState = "running"
|
||||||
StateStarting ContainerState = "starting" // running but health: starting
|
StateStarting ContainerState = "starting" // running but health: starting
|
||||||
StateUnhealthy ContainerState = "unhealthy" // running but health: unhealthy
|
StateUnhealthy ContainerState = "unhealthy" // running but health: unhealthy
|
||||||
StateStopped ContainerState = "stopped"
|
StateStopped ContainerState = "stopped"
|
||||||
StateRestarting ContainerState = "restarting"
|
StateRestarting ContainerState = "restarting"
|
||||||
StateExited ContainerState = "exited"
|
StateExited ContainerState = "exited"
|
||||||
@@ -52,12 +52,12 @@ type HealthProbeResult struct {
|
|||||||
|
|
||||||
// HealthCheckDetail holds the result of a single health check item.
|
// HealthCheckDetail holds the result of a single health check item.
|
||||||
type HealthCheckDetail struct {
|
type HealthCheckDetail struct {
|
||||||
Type string `json:"type"` // "http", "api", "tcp"
|
Type string `json:"type"` // "http", "api", "tcp"
|
||||||
Target string `json:"target"` // e.g. ":3456/api/v1/info"
|
Target string `json:"target"` // e.g. ":3456/api/v1/info"
|
||||||
Healthy bool `json:"healthy"`
|
Healthy bool `json:"healthy"`
|
||||||
Status int `json:"status,omitempty"` // HTTP status code (for http/api)
|
Status int `json:"status,omitempty"` // HTTP status code (for http/api)
|
||||||
Latency string `json:"latency"` // e.g. "45ms"
|
Latency string `json:"latency"` // e.g. "45ms"
|
||||||
Error string `json:"error,omitempty"` // error message if unhealthy
|
Error string `json:"error,omitempty"` // error message if unhealthy
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stack represents a docker compose stack on disk.
|
// Stack represents a docker compose stack on disk.
|
||||||
@@ -66,14 +66,14 @@ type Stack struct {
|
|||||||
Meta Metadata `json:"meta"`
|
Meta Metadata `json:"meta"`
|
||||||
ComposePath string `json:"compose_path"`
|
ComposePath string `json:"compose_path"`
|
||||||
State ContainerState `json:"state"`
|
State ContainerState `json:"state"`
|
||||||
Deployed bool `json:"deployed"` // Has app.yaml with deployed=true
|
Deployed bool `json:"deployed"` // Has app.yaml with deployed=true
|
||||||
Protected bool `json:"protected"`
|
Protected bool `json:"protected"`
|
||||||
Orphaned bool `json:"orphaned"` // Deployed but no catalog template
|
Orphaned bool `json:"orphaned"` // Deployed but no catalog template
|
||||||
Containers []ContainerInfo `json:"containers"`
|
Containers []ContainerInfo `json:"containers"`
|
||||||
AppConfig *AppConfig `json:"app_config,omitempty"`
|
AppConfig *AppConfig `json:"app_config,omitempty"`
|
||||||
Deploying bool `json:"deploying"` // compose up in progress
|
Deploying bool `json:"deploying"` // compose up in progress
|
||||||
DeployError string `json:"deploy_error,omitempty"` // last async deploy error
|
DeployError string `json:"deploy_error,omitempty"` // last async deploy error
|
||||||
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
|
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
|
||||||
LastUpdated time.Time `json:"last_updated"`
|
LastUpdated time.Time `json:"last_updated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +93,9 @@ type Manager struct {
|
|||||||
migJob *MigrationJob
|
migJob *MigrationJob
|
||||||
settings *settings.Settings
|
settings *settings.Settings
|
||||||
sysDataPath string
|
sysDataPath string
|
||||||
backupRunning func() bool // mutual exclusion with the backup orchestrator (Change 3)
|
backupRunning func() bool // mutual exclusion with the backup orchestrator (Change 3)
|
||||||
testSeams *migSeams // nil in production; tests inject fakes
|
migDoneHook func(*MigrationJob) // fired on successful completion (decommission policy lives in caller)
|
||||||
|
testSeams *migSeams // nil in production; tests inject fakes
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new stack manager.
|
// NewManager creates a new stack manager.
|
||||||
@@ -1105,4 +1106,4 @@ func (m *Manager) getCatalogTemplateSlugs() map[string]bool {
|
|||||||
m.logger.Printf("[DEBUG] [stacks] getCatalogTemplateSlugs: found %d template slugs in %s", len(slugs), cacheDir)
|
m.logger.Printf("[DEBUG] [stacks] getCatalogTemplateSlugs: found %d template slugs in %s", len(slugs), cacheDir)
|
||||||
}
|
}
|
||||||
return slugs
|
return slugs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ type MigrationJob struct {
|
|||||||
StartedAt time.Time `json:"started_at"`
|
StartedAt time.Time `json:"started_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
FinishedAt time.Time `json:"finished_at,omitempty"`
|
FinishedAt time.Time `json:"finished_at,omitempty"`
|
||||||
|
|
||||||
|
// DecommissionOnDone: when set, the migration was started by the decommission flow — on successful
|
||||||
|
// completion the done-hook soft-marks the source registry path + tells the agent to decommission it.
|
||||||
|
// The engine itself does NOT decommission; it only fires the hook (the policy lives in the caller).
|
||||||
|
DecommissionOnDone bool `json:"decommission_on_done,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *MigrationJob) clone() *MigrationJob {
|
func (j *MigrationJob) clone() *MigrationJob {
|
||||||
@@ -185,17 +190,27 @@ func (m *Manager) MigrationStatus() *MigrationJob {
|
|||||||
return m.migJob.clone()
|
return m.migJob.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMigrationDoneHook registers a callback fired (in the migration goroutine) when a migration
|
||||||
|
// completes successfully. The decommission flow uses it to soft-mark + agent-decommission the source.
|
||||||
|
func (m *Manager) SetMigrationDoneHook(fn func(*MigrationJob)) { m.migDoneHook = fn }
|
||||||
|
|
||||||
// MigrateAll moves the whole namespace off sourcePath onto targetPath.
|
// MigrateAll moves the whole namespace off sourcePath onto targetPath.
|
||||||
func (m *Manager) MigrateAll(ctx context.Context, sourcePath, targetPath string) (string, error) {
|
func (m *Manager) MigrateAll(ctx context.Context, sourcePath, targetPath string) (string, error) {
|
||||||
return m.startMigration("all", sourcePath, "", targetPath)
|
return m.startMigration("all", sourcePath, "", targetPath, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateAllAndDecommission moves the whole namespace then (on success) fires the done-hook so the
|
||||||
|
// caller decommissions the now-empty source drive.
|
||||||
|
func (m *Manager) MigrateAllAndDecommission(ctx context.Context, sourcePath, targetPath string) (string, error) {
|
||||||
|
return m.startMigration("all", sourcePath, "", targetPath, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MigrateApp moves a single app's data subtree onto targetPath.
|
// MigrateApp moves a single app's data subtree onto targetPath.
|
||||||
func (m *Manager) MigrateApp(ctx context.Context, appName, targetPath string) (string, error) {
|
func (m *Manager) MigrateApp(ctx context.Context, appName, targetPath string) (string, error) {
|
||||||
return m.startMigration("app", "", appName, targetPath)
|
return m.startMigration("app", "", appName, targetPath, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) startMigration(scope, sourcePath, appName, targetPath string) (string, error) {
|
func (m *Manager) startMigration(scope, sourcePath, appName, targetPath string, decommission bool) (string, error) {
|
||||||
if err := m.acquireMigrating(); err != nil {
|
if err := m.acquireMigrating(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -207,10 +222,11 @@ func (m *Manager) startMigration(scope, sourcePath, appName, targetPath string)
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
j := &MigrationJob{
|
j := &MigrationJob{
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
Target: filepath.Clean(targetPath),
|
Target: filepath.Clean(targetPath),
|
||||||
Units: map[string]*MigUnit{},
|
Units: map[string]*MigUnit{},
|
||||||
StartedAt: time.Now().UTC(),
|
StartedAt: time.Now().UTC(),
|
||||||
|
DecommissionOnDone: decommission,
|
||||||
}
|
}
|
||||||
j.ID = "mig-" + j.StartedAt.Format("20060102-150405")
|
j.ID = "mig-" + j.StartedAt.Format("20060102-150405")
|
||||||
|
|
||||||
@@ -389,6 +405,9 @@ func (m *Manager) runMigration(ctx context.Context, j *MigrationJob) {
|
|||||||
}
|
}
|
||||||
if j.Phase == PhaseDone {
|
if j.Phase == PhaseDone {
|
||||||
m.logger.Printf("[INFO] [migrate] %s complete: %s → %s (%d app(s))", j.ID, j.Source, j.Target, len(j.Apps))
|
m.logger.Printf("[INFO] [migrate] %s complete: %s → %s (%d app(s))", j.ID, j.Source, j.Target, len(j.Apps))
|
||||||
|
if m.migDoneHook != nil {
|
||||||
|
m.migDoneHook(j.clone()) // decommission policy (soft-mark + agent) lives in the hook
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFinalizeDecommission_SoftMarksAndCallsAgent: finalizeDecommissionWith soft-marks the registry
|
||||||
|
// (entry retained, MigratedTo set) and calls the agent's Decommission. Used by both the migrate-done
|
||||||
|
// hook (MigratedTo=target) and decommission-anyway (MigratedTo="").
|
||||||
|
func TestFinalizeDecommission_SoftMarksAndCallsAgent(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true})
|
||||||
|
agent := &mockAgent{}
|
||||||
|
|
||||||
|
if err := s.finalizeDecommissionWith(context.Background(), agent, "/mnt/old", "/mnt/new"); err != nil {
|
||||||
|
t.Fatalf("finalize: %v", err)
|
||||||
|
}
|
||||||
|
// soft-marked, entry retained
|
||||||
|
if !s.settings.IsDecommissioned("/mnt/old") {
|
||||||
|
t.Errorf("path not soft-marked decommissioned")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
|
if sp.Path == "/mnt/old" {
|
||||||
|
found = true
|
||||||
|
if sp.MigratedTo != "/mnt/new" {
|
||||||
|
t.Errorf("MigratedTo = %q, want /mnt/new", sp.MigratedTo)
|
||||||
|
}
|
||||||
|
if sp.Schedulable || sp.IsDefault {
|
||||||
|
t.Errorf("decommissioned entry should not be schedulable/default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("entry was REMOVED — soft marker must retain it (blocks A1 resurrection)")
|
||||||
|
}
|
||||||
|
// agent told to decommission
|
||||||
|
if len(agent.decommissionCalls) != 1 || agent.decommissionCalls[0] != "/mnt/old" {
|
||||||
|
t.Errorf("agent.Decommission calls = %v, want [/mnt/old]", agent.decommissionCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOnMigrationDone_UnflaggedIsNoop: a plain migrate-all completion (DecommissionOnDone=false) must
|
||||||
|
// NOT decommission the source. (The flagged path's effects are covered by the finalize test; it would
|
||||||
|
// require a live agent client here.) Companion: if the early-return guard were removed, this would try
|
||||||
|
// to reach agentClient() and the path would change — so the no-op assertion pins the guard.
|
||||||
|
func TestOnMigrationDone_UnflaggedIsNoop(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true})
|
||||||
|
s.OnMigrationDone(&stacks.MigrationJob{Source: "/mnt/old", Target: "/mnt/new", DecommissionOnDone: false})
|
||||||
|
if s.settings.IsDecommissioned("/mnt/old") {
|
||||||
|
t.Errorf("unflagged migration must not decommission the source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReEnrollClearMarker_Change4: re-enrolling a decommissioned path clears the marker + restores
|
||||||
|
// Schedulable so its apps' missing-storage indicator clears. (Companion: a non-decommissioned path is
|
||||||
|
// untouched — returns false.)
|
||||||
|
func TestReEnrollClearMarker_Change4(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/usb", Schedulable: true})
|
||||||
|
_ = s.settings.SetDecommissioned("/mnt/usb", "")
|
||||||
|
if !s.settings.IsDecommissioned("/mnt/usb") {
|
||||||
|
t.Fatal("precondition: should be decommissioned")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared, err := s.reEnrollClearMarker("/mnt/usb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reEnrollClearMarker: %v", err)
|
||||||
|
}
|
||||||
|
if !cleared {
|
||||||
|
t.Errorf("expected cleared=true for a decommissioned path")
|
||||||
|
}
|
||||||
|
if s.settings.IsDecommissioned("/mnt/usb") {
|
||||||
|
t.Errorf("marker not cleared")
|
||||||
|
}
|
||||||
|
if !s.settings.IsStoragePathSchedulable("/mnt/usb") {
|
||||||
|
t.Errorf("Schedulable not restored")
|
||||||
|
}
|
||||||
|
// companion: a non-decommissioned path is a no-op
|
||||||
|
if c, _ := s.reEnrollClearMarker("/mnt/usb"); c {
|
||||||
|
t.Errorf("non-decommissioned path should return cleared=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMissingStorageLabel: an app's HDD_PATH on a decommissioned/disconnected/absent registry path is
|
||||||
|
// "missing"; on a present+schedulable path it is not; an empty HDD_PATH (SSD) is never missing.
|
||||||
|
func TestMissingStorageLabel(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/live", Label: "Élő", Schedulable: true})
|
||||||
|
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/dead", Label: "Holt", Schedulable: true})
|
||||||
|
_ = s.settings.SetDecommissioned("/mnt/dead", "")
|
||||||
|
|
||||||
|
if _, missing := s.missingStorageLabel(""); missing {
|
||||||
|
t.Errorf("empty HDD_PATH (SSD) must not be missing")
|
||||||
|
}
|
||||||
|
if _, missing := s.missingStorageLabel("/mnt/live"); missing {
|
||||||
|
t.Errorf("present+schedulable path must not be missing")
|
||||||
|
}
|
||||||
|
if label, missing := s.missingStorageLabel("/mnt/dead"); !missing || label != "Holt" {
|
||||||
|
t.Errorf("decommissioned path: missing=%v label=%q, want true/Holt", missing, label)
|
||||||
|
}
|
||||||
|
if label, missing := s.missingStorageLabel("/mnt/gone"); !missing || label == "" {
|
||||||
|
t.Errorf("unregistered path: missing=%v label=%q, want true/non-empty", missing, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,6 +151,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
data := s.baseData("dashboard", "Vezérlőpult")
|
data := s.baseData("dashboard", "Vezérlőpult")
|
||||||
data["Stacks"] = deployedStacks
|
data["Stacks"] = deployedStacks
|
||||||
|
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
|
||||||
data["RunningCount"] = running
|
data["RunningCount"] = running
|
||||||
data["StoppedCount"] = stopped
|
data["StoppedCount"] = stopped
|
||||||
data["TotalCount"] = len(stackList)
|
data["TotalCount"] = len(stackList)
|
||||||
@@ -195,7 +196,9 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
data := s.baseData("stacks", "Alkalmazások")
|
data := s.baseData("stacks", "Alkalmazások")
|
||||||
data["Stacks"] = s.stackMgr.GetStacks()
|
allStacks := s.stackMgr.GetStacks()
|
||||||
|
data["Stacks"] = allStacks
|
||||||
|
data["MissingStorage"] = s.missingStorageMap(allStacks)
|
||||||
|
|
||||||
// Build storage label lookup for deployed apps
|
// Build storage label lookup for deployed apps
|
||||||
storageLabels := make(map[string]string) // stack name → storage label
|
storageLabels := make(map[string]string) // stack name → storage label
|
||||||
@@ -486,6 +489,9 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
|||||||
}
|
}
|
||||||
data["MigrateTargets"] = targets
|
data["MigrateTargets"] = targets
|
||||||
data["MigrateCurrent"] = current
|
data["MigrateCurrent"] = current
|
||||||
|
if label, missing := s.missingStorageLabel(current); missing {
|
||||||
|
data["MissingStorageLabel"] = label
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.executeTemplate(w, r, "app_info", data)
|
s.executeTemplate(w, r, "app_info", data)
|
||||||
@@ -1116,6 +1122,41 @@ func (s *Server) appsUsingPath(storagePath string) []string {
|
|||||||
return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath)
|
return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// missingStorageLabel reports whether a deployed app's HDD_PATH resolves to an UNAVAILABLE registry
|
||||||
|
// path (decommissioned, disconnected, or no longer registered) and returns its human label. An app
|
||||||
|
// with no HDD_PATH (SSD-resident) is never "missing".
|
||||||
|
func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
|
||||||
|
if hddPath == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
|
if sp.Path == hddPath {
|
||||||
|
if sp.Decommissioned || sp.Disconnected {
|
||||||
|
return s.settings.GetStorageLabel(hddPath), true
|
||||||
|
}
|
||||||
|
return "", false // present + available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingStorageMap returns stack-name → storage label for every deployed app whose data drive is
|
||||||
|
// currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator).
|
||||||
|
func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, st := range list {
|
||||||
|
if !st.Deployed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil {
|
||||||
|
if label, missing := s.missingStorageLabel(cfg.Env["HDD_PATH"]); missing {
|
||||||
|
out[st.Name] = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the
|
// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the
|
||||||
// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is
|
// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is
|
||||||
// the "name the apps that break" list for the type-to-confirm wipe/eject UI.
|
// the "name the apps that break" list for the type-to-confirm wipe/eject UI.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ type diskAgent interface {
|
|||||||
FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error)
|
FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error)
|
||||||
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
|
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
|
||||||
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
|
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
|
||||||
|
Decommission(ctx context.Context, where string) (agentapi.DecommissionResult, error)
|
||||||
GuestAttach(ctx context.Context, where string) error
|
GuestAttach(ctx context.Context, where string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,12 +197,39 @@ func (s *Server) pendingActivationDrives() []string {
|
|||||||
return pending
|
return pending
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reEnrollClearMarker un-retires a re-plugged decommissioned drive (Change 4): clears the soft marker
|
||||||
|
// and restores Schedulable so its apps' "missing storage" indicator clears. Returns true if it acted.
|
||||||
|
func (s *Server) reEnrollClearMarker(where string) (bool, error) {
|
||||||
|
if !s.settings.IsDecommissioned(where) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := s.settings.ClearDecommissioned(where); err != nil {
|
||||||
|
return false, fmt.Errorf("leszerelés visszavonása sikertelen: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.settings.SetSchedulable(where, true); err != nil {
|
||||||
|
return false, fmt.Errorf("ütemezhetőség visszaállítása sikertelen: %w", err)
|
||||||
|
}
|
||||||
|
s.logger.Printf("[INFO] [web] re-enrolled decommissioned drive — marker cleared: %s", where)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// registerStoragePath records a freshly-mounted path in the StoragePath registry (schedulable by
|
// registerStoragePath records a freshly-mounted path in the StoragePath registry (schedulable by
|
||||||
// default) and refreshes the FileBrowser mounts so it's usable immediately.
|
// default) and refreshes the FileBrowser mounts so it's usable immediately.
|
||||||
func (s *Server) registerStoragePath(where, label string, setDefault bool) error {
|
func (s *Server) registerStoragePath(where, label string, setDefault bool) error {
|
||||||
if strings.TrimSpace(label) == "" {
|
if strings.TrimSpace(label) == "" {
|
||||||
label = settings.InferStorageLabel(where)
|
label = settings.InferStorageLabel(where)
|
||||||
}
|
}
|
||||||
|
// Change 4: re-enrolling a previously-DECOMMISSIONED drive must un-retire it. AddStoragePath
|
||||||
|
// dedups a re-register into a no-op, so without this the soft marker would persist forever and the
|
||||||
|
// apps' "missing storage" indicator would never clear.
|
||||||
|
if cleared, err := s.reEnrollClearMarker(where); err != nil {
|
||||||
|
return err
|
||||||
|
} else if cleared {
|
||||||
|
if s.stackMgr != nil {
|
||||||
|
go s.SyncFileBrowserMounts()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
sp := settings.StoragePath{
|
sp := settings.StoragePath{
|
||||||
Path: where,
|
Path: where,
|
||||||
Label: label,
|
Label: label,
|
||||||
@@ -251,6 +280,8 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.handleStorageMigrateApp(w, r)
|
s.handleStorageMigrateApp(w, r)
|
||||||
case r.URL.Path == "/api/storage/migrate/status" && r.Method == http.MethodGet:
|
case r.URL.Path == "/api/storage/migrate/status" && r.Method == http.MethodGet:
|
||||||
s.handleStorageMigrateStatus(w, r)
|
s.handleStorageMigrateStatus(w, r)
|
||||||
|
case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost:
|
||||||
|
s.handleStorageDecommission(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -300,6 +331,113 @@ func (s *Server) handleStorageMigrateStatus(w http.ResponseWriter, r *http.Reque
|
|||||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"job": s.stackMgr.MigrationStatus()})
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"job": s.stackMgr.MigrationStatus()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleStorageDecommission is the SELF-SERVE drive decommission (B2b). Exactly two choices, no partial
|
||||||
|
// (Change 2): mode="migrate" moves ALL apps to a target then decommissions the now-empty source (the
|
||||||
|
// done-hook finalizes); mode="anyway" decommissions immediately, stopping the apps (keeping their
|
||||||
|
// HDD_PATH so they show "missing storage"). Non-destructive — the drive's data is never formatted.
|
||||||
|
func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Where string `json:"where"`
|
||||||
|
Mode string `json:"mode"` // "migrate" | "anyway"
|
||||||
|
Target string `json:"target"` // required for mode=migrate
|
||||||
|
MountName string `json:"mount_name"` // type-to-confirm for mode=anyway
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Where = path.Clean(strings.TrimSpace(req.Where))
|
||||||
|
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Mode {
|
||||||
|
case "migrate":
|
||||||
|
if strings.TrimSpace(req.Target) == "" {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "céltároló kötelező az áthelyezéshez", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Start the migration; the done-hook (onMigrationDone) soft-marks + agent-decommissions the
|
||||||
|
// source once every app has moved and come up on the target. A VALIDATE refusal returns here.
|
||||||
|
id, err := s.stackMgr.MigrateAllAndDecommission(r.Context(), req.Where, strings.TrimSpace(req.Target))
|
||||||
|
if err != nil {
|
||||||
|
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id, "mode": "migrate"})
|
||||||
|
|
||||||
|
case "anyway":
|
||||||
|
// Type-to-confirm: the typed name must match the mount basename exactly (mirrors wipe).
|
||||||
|
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Stop the apps that live on this drive — but KEEP their HDD_PATH so the dashboard can name the
|
||||||
|
// drive in the "missing storage" indicator until the customer re-enrolls or migrates.
|
||||||
|
var stopped []string
|
||||||
|
for _, st := range s.stackMgr.GetStacks() {
|
||||||
|
if !st.Deployed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil && cfg.Env["HDD_PATH"] == req.Where {
|
||||||
|
if err := s.stackMgr.StopStack(st.Name); err != nil {
|
||||||
|
s.logger.Printf("[WARN] [web] decommission: stop %s failed: %v", st.Name, err)
|
||||||
|
}
|
||||||
|
stopped = append(stopped, st.Meta.DisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.finalizeDecommission(r.Context(), req.Where, ""); err != nil {
|
||||||
|
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"decommissioned": true, "where": req.Where, "stopped_apps": stopped})
|
||||||
|
|
||||||
|
default:
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "ismeretlen mód (migrate vagy anyway)", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalizeDecommission resolves the agent client then soft-marks + decommissions (see *With).
|
||||||
|
func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo string) error {
|
||||||
|
agent, err := s.agentClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.finalizeDecommissionWith(ctx, agent, where, migratedTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalizeDecommissionWith soft-marks the registry path (keeping the entry — blocks A1 resurrection)
|
||||||
|
// and tells the agent to decommission the drive (intent + unmount; never formats). migratedTo is the
|
||||||
|
// target path for a migrate-then-decommission, or "" for decommission-anyway. The agent is injected so
|
||||||
|
// the orchestration is testable without a live agent.
|
||||||
|
func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent, where, migratedTo string) error {
|
||||||
|
if err := s.settings.SetDecommissioned(where, migratedTo); err != nil {
|
||||||
|
return fmt.Errorf("nyilvántartás frissítése sikertelen: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := agent.Decommission(ctx, where); err != nil {
|
||||||
|
return fmt.Errorf("a meghajtó leszerelése sikertelen: %w", err)
|
||||||
|
}
|
||||||
|
if s.stackMgr != nil {
|
||||||
|
go s.SyncFileBrowserMounts()
|
||||||
|
}
|
||||||
|
s.logger.Printf("[INFO] [web] storage decommissioned: %s (migrated_to=%q)", where, migratedTo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onMigrationDone is the migration completion hook: when a decommission-initiated migration succeeds,
|
||||||
|
// the source drive is now empty (all apps flipped to the target), so soft-mark + agent-decommission it.
|
||||||
|
func (s *Server) OnMigrationDone(j *stacks.MigrationJob) {
|
||||||
|
if j == nil || !j.DecommissionOnDone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.finalizeDecommission(context.Background(), j.Source, j.Target); err != nil {
|
||||||
|
s.logger.Printf("[ERROR] [web] post-migration decommission of %s failed: %v", j.Source, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Printf("[INFO] [web] source %s decommissioned after migration to %s", j.Source, j.Target)
|
||||||
|
}
|
||||||
|
|
||||||
type storageProvReq struct {
|
type storageProvReq struct {
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
FSType string `json:"fstype"`
|
FSType string `json:"fstype"`
|
||||||
|
|||||||
@@ -27,14 +27,15 @@ func TestTemplatesParse(t *testing.T) {
|
|||||||
|
|
||||||
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
|
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
|
||||||
type mockAgent struct {
|
type mockAgent struct {
|
||||||
disks agentapi.DisksResponse
|
disks agentapi.DisksResponse
|
||||||
formatRes agentapi.FormatResult
|
formatRes agentapi.FormatResult
|
||||||
formatErr error
|
formatErr error
|
||||||
assignErr error
|
assignErr error
|
||||||
assignCalls []assignCall
|
assignCalls []assignCall
|
||||||
disksCalls int
|
disksCalls int
|
||||||
formatCalls []formatCall
|
formatCalls []formatCall
|
||||||
guestAttachCalls []string
|
guestAttachCalls []string
|
||||||
|
decommissionCalls []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type assignCall struct{ uuid, where, fstype string }
|
type assignCall struct{ uuid, where, fstype string }
|
||||||
@@ -58,6 +59,10 @@ func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string)
|
|||||||
func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectResult, error) {
|
func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectResult, error) {
|
||||||
return agentapi.EjectResult{Ejected: where}, nil
|
return agentapi.EjectResult{Ejected: where}, nil
|
||||||
}
|
}
|
||||||
|
func (m *mockAgent) Decommission(_ context.Context, where string) (agentapi.DecommissionResult, error) {
|
||||||
|
m.decommissionCalls = append(m.decommissionCalls, where)
|
||||||
|
return agentapi.DecommissionResult{Decommissioned: where}, nil
|
||||||
|
}
|
||||||
func (m *mockAgent) GuestAttach(_ context.Context, where string) error {
|
func (m *mockAgent) GuestAttach(_ context.Context, where string) error {
|
||||||
m.guestAttachCalls = append(m.guestAttachCalls, where)
|
m.guestAttachCalls = append(m.guestAttachCalls, where)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -24,6 +24,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .MissingStorageLabel}}
|
||||||
|
<div class="alert alert-warning" style="margin-top:1rem">
|
||||||
|
<strong>⚠ Hiányzó tárhely: {{.MissingStorageLabel}}</strong><br>
|
||||||
|
Ennek az alkalmazásnak az adattárolója jelenleg nem elérhető, ezért le van állítva. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<!-- Hero section -->
|
<!-- Hero section -->
|
||||||
<div class="app-info-hero">
|
<div class="app-info-hero">
|
||||||
<img class="app-info-logo" src="{{logoURL .Meta.Slug}}"
|
<img class="app-info-logo" src="{{logoURL .Meta.Slug}}"
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
<div class="stack-actions">
|
<div class="stack-actions">
|
||||||
<span class="stack-state-label">{{stateLabel .State}}</span>
|
<span class="stack-state-label">{{stateLabel .State}}</span>
|
||||||
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||||
|
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="badge badge-missing-storage" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre.">⚠ Hiányzó tárhely: {{$ms}}</span>{{end}}
|
||||||
{{if and .Deployed (routeUnpublished .State)}}<span class="badge badge-route-unpublished" title="A proxy (Traefik) csak egészséges konténerhez publikál nyilvános útvonalat. Amíg az alkalmazás nem egészséges, az URL 404-et ad, pedig a konténer fut.">⚠ URL nem elérhető</span>{{end}}
|
{{if and .Deployed (routeUnpublished .State)}}<span class="badge badge-route-unpublished" title="A proxy (Traefik) csak egészséges konténerhez publikál nyilvános útvonalat. Amíg az alkalmazás nem egészséges, az URL 404-et ad, pedig a konténer fut.">⚠ URL nem elérhető</span>{{end}}
|
||||||
|
|
||||||
{{if .Protected}}
|
{{if .Protected}}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span>
|
<span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span>
|
||||||
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||||
|
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="badge badge-missing-storage" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat.">⚠ Hiányzó tárhely: {{$ms}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .Meta.Description}}
|
{{if .Meta.Description}}
|
||||||
|
|||||||
@@ -1249,6 +1249,13 @@ a.stat-card:hover {
|
|||||||
color: var(--orange);
|
color: var(--orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* B2b: a deployed app whose data drive was decommissioned/disconnected — distinct warning, not error-spam. */
|
||||||
|
.badge-missing-storage {
|
||||||
|
background: var(--orange-bg);
|
||||||
|
color: var(--orange);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* F5: an unhealthy/restarting deployed app has its public route withheld by Traefik (404 at the URL). */
|
/* F5: an unhealthy/restarting deployed app has its public route withheld by Traefik (404 at the URL). */
|
||||||
.badge-route-unpublished {
|
.badge-route-unpublished {
|
||||||
background: var(--orange-bg);
|
background: var(--orange-bg);
|
||||||
|
|||||||
Reference in New Issue
Block a user