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:
@@ -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["Stacks"] = deployedStacks
|
||||
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
|
||||
data["RunningCount"] = running
|
||||
data["StoppedCount"] = stopped
|
||||
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) {
|
||||
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
|
||||
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["MigrateCurrent"] = current
|
||||
if label, missing := s.missingStorageLabel(current); missing {
|
||||
data["MissingStorageLabel"] = label
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"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)
|
||||
AssignDisk(ctx context.Context, uuid, where, fstype, options string) 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
|
||||
}
|
||||
|
||||
@@ -195,12 +197,39 @@ func (s *Server) pendingActivationDrives() []string {
|
||||
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
|
||||
// default) and refreshes the FileBrowser mounts so it's usable immediately.
|
||||
func (s *Server) registerStoragePath(where, label string, setDefault bool) error {
|
||||
if strings.TrimSpace(label) == "" {
|
||||
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{
|
||||
Path: where,
|
||||
Label: label,
|
||||
@@ -251,6 +280,8 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleStorageMigrateApp(w, r)
|
||||
case r.URL.Path == "/api/storage/migrate/status" && r.Method == http.MethodGet:
|
||||
s.handleStorageMigrateStatus(w, r)
|
||||
case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost:
|
||||
s.handleStorageDecommission(w, r)
|
||||
default:
|
||||
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()})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Device string `json:"device"`
|
||||
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.
|
||||
type mockAgent struct {
|
||||
disks agentapi.DisksResponse
|
||||
formatRes agentapi.FormatResult
|
||||
formatErr error
|
||||
assignErr error
|
||||
assignCalls []assignCall
|
||||
disksCalls int
|
||||
formatCalls []formatCall
|
||||
guestAttachCalls []string
|
||||
disks agentapi.DisksResponse
|
||||
formatRes agentapi.FormatResult
|
||||
formatErr error
|
||||
assignErr error
|
||||
assignCalls []assignCall
|
||||
disksCalls int
|
||||
formatCalls []formatCall
|
||||
guestAttachCalls []string
|
||||
decommissionCalls []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) {
|
||||
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 {
|
||||
m.guestAttachCalls = append(m.guestAttachCalls, where)
|
||||
return nil
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
</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 -->
|
||||
<div class="app-info-hero">
|
||||
<img class="app-info-logo" src="{{logoURL .Meta.Slug}}"
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
<div class="stack-actions">
|
||||
<span class="stack-state-label">{{stateLabel .State}}</span>
|
||||
{{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 .Protected}}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
</div>
|
||||
<span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span>
|
||||
{{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>
|
||||
|
||||
{{if .Meta.Description}}
|
||||
|
||||
@@ -1249,6 +1249,13 @@ a.stat-card:hover {
|
||||
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). */
|
||||
.badge-route-unpublished {
|
||||
background: var(--orange-bg);
|
||||
|
||||
Reference in New Issue
Block a user