controller v0.92.0: NAS network storage Part A2 (registry + UI + per-share health)
Controller-side of NAS network storage, proxying to agent A1 /netstorage/*. Distinct 'network' storage kind (no drive lifecycle); add/list/remove + per-share health UI; unreachable NAS is a recoverable warning, never the drive missing/stop cascade; SMB creds pass through to the agent, never persisted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
This commit is contained in:
@@ -1,5 +1,31 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.92.0 — NAS network storage Part A2: registry + UI + per-share health (2026-06-30)
|
||||||
|
The controller side of NAS network storage, proxying to the validated agent foundation (felhom-agent
|
||||||
|
v0.50.0 `/netstorage/*`). An operator can add a customer's NAS share and point a media app at it — all via
|
||||||
|
the UI. A NAS is a **distinct storage kind** (NOT a drive): no enroll/eject/decommission/migrate/wipe/SMART.
|
||||||
|
- **`internal/agentapi/client.go`:** `AddNetStorage`/`ListNetStorage`/`RemoveNetStorage` + the
|
||||||
|
`NetworkMountStatus` mirror (health `ok|idle|unreachable`; `idle` is benign, only `unreachable` degraded).
|
||||||
|
The SMB credential is passed STRAIGHT THROUGH to the agent (which writes the 0600 file) — **never persisted
|
||||||
|
by the controller**.
|
||||||
|
- **`internal/settings/settings.go`:** `StoragePath.Kind` discriminator (`""`/`drive` | `network`) + the
|
||||||
|
network descriptors (Protocol/Server/Export/MappedUID/MappedGID — **no password**); `IsNetwork()` +
|
||||||
|
`IsNetworkStoragePath()`; `NetworkMountRoot`.
|
||||||
|
- **`internal/web/netstorage_handlers.go` (NEW):** `POST /api/storage/netstorage/{add,remove}` +
|
||||||
|
`GET /api/storage/netstorage`; registers/deregisters a Kind=network `StoragePath`; merges the agent's live
|
||||||
|
per-share health for the UI.
|
||||||
|
- **Kind-gating (the safety centerpiece):** `refuseNetworkLifecycle` blocks the drive ops
|
||||||
|
(eject/decommission/migrate/wipe) on a network path server-side; the drive-absent **gate**
|
||||||
|
(`planDriveGates`) and the **missing-storage** surface now SKIP network paths — so an `unreachable` NAS is a
|
||||||
|
**recoverable warning**, never the drive "missing → stopped" cascade (Scenario C). `networkStorageWarnings`
|
||||||
|
drives a distinct "Hálózati tárhely nem elérhető" app-card badge.
|
||||||
|
- **UI (`settings.html`):** a "Hálózati tárhely (NAS)" section — add form (NFS/SMB, server/export, uid,
|
||||||
|
SMB creds), per-share health badges, remove. Network shares are auto-selectable as a media app's `HDD_PATH`
|
||||||
|
(they register Schedulable). Hungarian, minimal emoji.
|
||||||
|
- Tests: agentapi round-trip (creds forwarded, health states); registry Kind-gate **companion** (drive
|
||||||
|
lifecycle refuses a network path; a drive path is not gate-refused); `unreachable`≠`missing` **companion**
|
||||||
|
(the drive gate stops an absent drive but NOT a network path). `go build/vet/test ./...` green.
|
||||||
|
|
||||||
### v0.91.0 — F2: alert on born/persistent-down channel (not only transitions) (2026-06-29)
|
### v0.91.0 — F2: alert on born/persistent-down channel (not only transitions) (2026-06-29)
|
||||||
- **What:** closes F2 from the full-stack testrun — a channel failure present at **startup/reseed**
|
- **What:** closes F2 from the full-stack testrun — a channel failure present at **startup/reseed**
|
||||||
(e.g. the controller boots right after a leaf regen → first observation is `pin_mismatch`) was
|
(e.g. the controller boots right after a leaf regen → first observation is `pin_mismatch`) was
|
||||||
|
|||||||
@@ -509,6 +509,92 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirme
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- NAS network storage (Part A2 → agent A1 /netstorage/*) ------------------------------
|
||||||
|
//
|
||||||
|
// A NAS share is a DISTINCT storage class from a drive: the controller proxies add/list/remove to the
|
||||||
|
// agent (which owns the host-side automount), holds NO mount authority, and persists NO SMB password
|
||||||
|
// (it passes the credentials straight through to the agent's add request — the agent writes the 0600
|
||||||
|
// creds file). There is NO eject/decommission/migrate/wipe/SMART here — those are drive-only.
|
||||||
|
|
||||||
|
// NetworkMountStatus mirrors the agent's GET /netstorage entry (A1). health ∈ {ok, idle, unreachable}:
|
||||||
|
// `idle` (reachable + automount idle-unmounted) is BENIGN, not a fault; only `unreachable` is degraded.
|
||||||
|
type NetworkMountStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Server string `json:"server"`
|
||||||
|
Export string `json:"export"`
|
||||||
|
Where string `json:"where"`
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
Mounted bool `json:"mounted"`
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
Health string `json:"health"` // ok | idle | unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreachable reports the degraded state (NAS not reachable). `idle` is explicitly NOT unreachable — an
|
||||||
|
// idle-unmounted automount is the normal steady state, never a warning.
|
||||||
|
func (n NetworkMountStatus) Unreachable() bool { return n.Health == "unreachable" }
|
||||||
|
|
||||||
|
// AddNetStorageRequest is the controller→agent POST /netstorage/add body (A1). Username/Password are SMB
|
||||||
|
// only and flow STRAIGHT THROUGH to the agent (which writes the 0600 creds file) — the controller never
|
||||||
|
// stores the password at rest.
|
||||||
|
type AddNetStorageRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Protocol string `json:"protocol"` // nfs | smb
|
||||||
|
Server string `json:"server"`
|
||||||
|
Export string `json:"export"`
|
||||||
|
MappedUID int `json:"mapped_uid"`
|
||||||
|
MappedGID int `json:"mapped_gid"`
|
||||||
|
IdleTimeoutSec int `json:"idle_timeout_sec,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"` // SMB secret — pass-through, never persisted
|
||||||
|
Password string `json:"password,omitempty"` // SMB secret — pass-through, never persisted
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetStorageAddResult mirrors the agent's add response (the in-guest path the media app's data dir points at).
|
||||||
|
type NetStorageAddResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Where string `json:"where"`
|
||||||
|
GuestPath string `json:"guest_path"`
|
||||||
|
HostUID int `json:"host_uid"`
|
||||||
|
HostGID int `json:"host_gid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNetStorage mounts a NAS share host-side (the agent automounts it; it propagates into this guest via
|
||||||
|
// the shared bind). Returns the in-guest path the media app's data dir is pointed at.
|
||||||
|
func (c *Client) AddNetStorage(ctx context.Context, req AddNetStorageRequest) (NetStorageAddResult, error) {
|
||||||
|
var out NetStorageAddResult
|
||||||
|
body, err := c.post(ctx, "/netstorage/add", req)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return out, fmt.Errorf("agentapi: decode /netstorage/add: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNetStorage returns the configured NAS shares + per-share liveness.
|
||||||
|
func (c *Client) ListNetStorage(ctx context.Context) ([]NetworkMountStatus, error) {
|
||||||
|
body, err := c.get(ctx, "/netstorage")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var wrap struct {
|
||||||
|
NetworkMounts []NetworkMountStatus `json:"network_mounts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||||
|
return nil, fmt.Errorf("agentapi: decode /netstorage: %w", err)
|
||||||
|
}
|
||||||
|
return wrap.NetworkMounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveNetStorage unmounts + removes a NAS share (the agent drops the mount + creds file). This is NOT a
|
||||||
|
// drive decommission/migrate — a NAS has no device lifecycle.
|
||||||
|
func (c *Client) RemoveNetStorage(ctx context.Context, name string) error {
|
||||||
|
_, err := c.post(ctx, "/netstorage/remove", map[string]string{"name": name})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// postWithStatus issues an authenticated JSON POST and returns the envelope's data payload + the HTTP
|
// postWithStatus issues an authenticated JSON POST and returns the envelope's data payload + the HTTP
|
||||||
// status, even on a non-2xx (so callers like FormatDisk can read a 403 refusal body). A transport or
|
// status, even on a non-2xx (so callers like FormatDisk can read a 403 refusal body). A transport or
|
||||||
// envelope-parse failure is still an error; an `ok:false` business refusal is NOT (the data carries it).
|
// envelope-parse failure is still an error; an `ok:false` business refusal is NOT (the data carries it).
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package agentapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func netStub(t *testing.T) (*httptest.Server, string, *struct {
|
||||||
|
addBody AddNetStorageRequest
|
||||||
|
removed string
|
||||||
|
}) {
|
||||||
|
captured := &struct {
|
||||||
|
addBody AddNetStorageRequest
|
||||||
|
removed string
|
||||||
|
}{}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /netstorage/add", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = decodeJSON(r, &captured.addBody)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"name":"media","protocol":"nfs","where":"/mnt/felhom-drives/media","guest_path":"/mnt/felhom-drives/media","host_uid":101000,"host_gid":101000}}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /netstorage", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":8200,"network_mounts":[
|
||||||
|
{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","where":"/mnt/felhom-drives/media","configured":true,"mounted":true,"reachable":true,"health":"ok"},
|
||||||
|
{"name":"photos","protocol":"smb","server":"10.0.0.5","export":"photos","where":"/mnt/felhom-drives/photos","configured":true,"mounted":false,"reachable":true,"health":"idle"},
|
||||||
|
{"name":"vids","protocol":"nfs","server":"10.0.0.6","export":"/srv/vids","where":"/mnt/felhom-drives/vids","configured":true,"mounted":true,"reachable":false,"health":"unreachable"}
|
||||||
|
]}}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /netstorage/remove", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
_ = decodeJSON(r, &body)
|
||||||
|
captured.removed = body.Name
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"name":"` + body.Name + `","removed":true}}`))
|
||||||
|
})
|
||||||
|
s := httptest.NewTLSServer(mux)
|
||||||
|
return s, strings.TrimPrefix(s.URL, "https://"), captured
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetStorage_Add_ForwardsCredsAndMapping(t *testing.T) {
|
||||||
|
s, ep, cap := netStub(t)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, ep)
|
||||||
|
|
||||||
|
res, err := c.AddNetStorage(context.Background(), AddNetStorageRequest{
|
||||||
|
Name: "media", Protocol: "smb", Server: "10.0.0.5", Export: "media",
|
||||||
|
MappedUID: 1000, MappedGID: 1000, Username: "u", Password: "p",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.GuestPath != "/mnt/felhom-drives/media" || res.HostUID != 101000 {
|
||||||
|
t.Fatalf("add result mismatch: %+v", res)
|
||||||
|
}
|
||||||
|
// The SMB creds + mapping must be forwarded to the agent verbatim (the agent writes the 0600 file).
|
||||||
|
if cap.addBody.Username != "u" || cap.addBody.Password != "p" {
|
||||||
|
t.Fatalf("creds not forwarded to the agent: %+v", cap.addBody)
|
||||||
|
}
|
||||||
|
if cap.addBody.MappedUID != 1000 || cap.addBody.Protocol != "smb" {
|
||||||
|
t.Fatalf("mapping/protocol not forwarded: %+v", cap.addBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetStorage_List_HealthStates(t *testing.T) {
|
||||||
|
s, ep, _ := netStub(t)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, ep)
|
||||||
|
|
||||||
|
mounts, err := c.ListNetStorage(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(mounts) != 3 {
|
||||||
|
t.Fatalf("want 3 mounts, got %d", len(mounts))
|
||||||
|
}
|
||||||
|
byName := map[string]NetworkMountStatus{}
|
||||||
|
for _, m := range mounts {
|
||||||
|
byName[m.Name] = m
|
||||||
|
}
|
||||||
|
// ok + idle are NOT degraded; only unreachable is.
|
||||||
|
if byName["media"].Unreachable() || byName["photos"].Unreachable() {
|
||||||
|
t.Fatalf("ok/idle must not be unreachable: %+v", byName)
|
||||||
|
}
|
||||||
|
if byName["photos"].Health != "idle" {
|
||||||
|
t.Fatalf("photos should be idle (benign), got %q", byName["photos"].Health)
|
||||||
|
}
|
||||||
|
if !byName["vids"].Unreachable() {
|
||||||
|
t.Fatalf("vids should be unreachable (degraded), got %q", byName["vids"].Health)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetStorage_Remove(t *testing.T) {
|
||||||
|
s, ep, cap := netStub(t)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, ep)
|
||||||
|
|
||||||
|
if err := c.RemoveNetStorage(context.Background(), "media"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cap.removed != "media" {
|
||||||
|
t.Fatalf("remove did not forward the name, got %q", cap.removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,9 +115,22 @@ type CrossDriveBackup struct {
|
|||||||
PreferredTarget string `json:"preferred_target,omitempty"`
|
PreferredTarget string `json:"preferred_target,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Storage path kinds. A DRIVE is a physical disk with the full enroll/eject/decommission/migrate/wipe
|
||||||
|
// lifecycle (agent /disks). A NETWORK share is a NAS (Part A2) proxied to the agent /netstorage — a
|
||||||
|
// DISTINCT class with NO drive lifecycle (no eject/decommission/migrate/wipe/SMART; remove is the only
|
||||||
|
// lifecycle action). An empty Kind means "drive" (back-compat with already-persisted paths).
|
||||||
|
const (
|
||||||
|
StorageKindDrive = "drive"
|
||||||
|
StorageKindNetwork = "network"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NetworkMountRoot is the in-guest path under which the agent propagates NAS shares (mirrors the agent's
|
||||||
|
// /mnt/felhom-drives bind root). A registered network path is NetworkMountRoot + "/" + <share name>.
|
||||||
|
const NetworkMountRoot = "/mnt/felhom-drives"
|
||||||
|
|
||||||
// StoragePath represents a registered external storage location.
|
// StoragePath represents a registered external storage location.
|
||||||
type StoragePath struct {
|
type StoragePath struct {
|
||||||
Path string `json:"path"` // e.g., "/mnt/hdd_1"
|
Path string `json:"path"` // e.g., "/mnt/hdd_1" (drive) or "/mnt/felhom-drives/<name>" (network)
|
||||||
Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB"
|
Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB"
|
||||||
IsDefault bool `json:"is_default,omitempty"` // new apps use this by default
|
IsDefault bool `json:"is_default,omitempty"` // new apps use this by default
|
||||||
Schedulable bool `json:"schedulable"` // whether new apps can be deployed here
|
Schedulable bool `json:"schedulable"` // whether new apps can be deployed here
|
||||||
@@ -128,8 +141,23 @@ type StoragePath struct {
|
|||||||
Decommissioned bool `json:"decommissioned,omitempty"` // true when drive data migrated to another
|
Decommissioned bool `json:"decommissioned,omitempty"` // true when drive data migrated to another
|
||||||
DecommissionedAt string `json:"decommissioned_at,omitempty"` // RFC3339 timestamp
|
DecommissionedAt string `json:"decommissioned_at,omitempty"` // RFC3339 timestamp
|
||||||
MigratedTo string `json:"migrated_to,omitempty"` // path of target drive
|
MigratedTo string `json:"migrated_to,omitempty"` // path of target drive
|
||||||
|
|
||||||
|
// Kind discriminates a physical drive ("" / "drive") from a NAS network share ("network"). A network
|
||||||
|
// share is bulk-media only and carries the fields below; the drive lifecycle does NOT apply to it.
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
// Network-storage descriptors (Kind=="network" only; mirror the agent A1 add request). NO password
|
||||||
|
// is stored — the SMB credential is passed through to the agent at add-time and never persisted here.
|
||||||
|
Protocol string `json:"protocol,omitempty"` // nfs | smb
|
||||||
|
Server string `json:"server,omitempty"`
|
||||||
|
Export string `json:"export,omitempty"`
|
||||||
|
MappedUID int `json:"mapped_uid,omitempty"`
|
||||||
|
MappedGID int `json:"mapped_gid,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsNetwork reports whether this is a NAS network-storage path (vs a physical drive). The drive
|
||||||
|
// lifecycle (eject/decommission/migrate/wipe/SMART) must NEVER be applied to a network path.
|
||||||
|
func (p StoragePath) IsNetwork() bool { return p.Kind == StorageKindNetwork }
|
||||||
|
|
||||||
// NotificationPrefs holds customer notification preferences.
|
// NotificationPrefs holds customer notification preferences.
|
||||||
type NotificationPrefs struct {
|
type NotificationPrefs struct {
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
@@ -846,6 +874,20 @@ func (s *Settings) IsStoragePathKnown(path string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsNetworkStoragePath reports whether `path` belongs to a registered NAS network-storage path
|
||||||
|
// (Kind=="network"). The drive lifecycle (eject/decommission/migrate/wipe) must refuse such a path —
|
||||||
|
// a NAS has no device lifecycle. Matches the exact path or a child under it.
|
||||||
|
func (s *Settings) IsNetworkStoragePath(path string) bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for _, sp := range s.StoragePaths {
|
||||||
|
if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") {
|
||||||
|
return sp.IsNetwork()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// IsStoragePathSchedulable returns whether a path belongs to a registered,
|
// IsStoragePathSchedulable returns whether a path belongs to a registered,
|
||||||
// schedulable (active) storage path. Returns false if the path is unknown,
|
// schedulable (active) storage path. Returns false if the path is unknown,
|
||||||
// disconnected, decommissioned, or inactive.
|
// disconnected, decommissioned, or inactive.
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
|
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
|
||||||
data["Stacks"] = deployedStacks
|
data["Stacks"] = deployedStacks
|
||||||
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
|
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
|
||||||
|
data["NetworkWarnings"] = s.networkStorageWarnings(deployedStacks) // NAS unreachable — recoverable warning, not "missing"
|
||||||
data["RunningCount"] = running
|
data["RunningCount"] = running
|
||||||
data["StoppedCount"] = stopped
|
data["StoppedCount"] = stopped
|
||||||
data["TotalCount"] = len(stackList)
|
data["TotalCount"] = len(stackList)
|
||||||
@@ -202,6 +203,7 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
allStacks := s.stackMgr.GetStacks()
|
allStacks := s.stackMgr.GetStacks()
|
||||||
data["Stacks"] = allStacks
|
data["Stacks"] = allStacks
|
||||||
data["MissingStorage"] = s.missingStorageMap(allStacks)
|
data["MissingStorage"] = s.missingStorageMap(allStacks)
|
||||||
|
data["NetworkWarnings"] = s.networkStorageWarnings(allStacks) // NAS unreachable — recoverable warning, not "missing"
|
||||||
|
|
||||||
// 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
|
||||||
@@ -915,6 +917,11 @@ func (s *Server) settingsData() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
var storageViews []StoragePathView
|
var storageViews []StoragePathView
|
||||||
for _, sp := range storagePaths {
|
for _, sp := range storagePaths {
|
||||||
|
// NAS network shares are rendered in their OWN section (NetworkStoragePaths) with the agent's
|
||||||
|
// per-share health — never in the physical-drive list (which offers eject/decommission/wipe).
|
||||||
|
if sp.IsNetwork() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
view := StoragePathView{
|
view := StoragePathView{
|
||||||
StoragePath: sp,
|
StoragePath: sp,
|
||||||
StoppedApps: sp.StoppedStacks,
|
StoppedApps: sp.StoppedStacks,
|
||||||
@@ -942,6 +949,9 @@ func (s *Server) settingsData() map[string]interface{} {
|
|||||||
storageViews = append(storageViews, view)
|
storageViews = append(storageViews, view)
|
||||||
}
|
}
|
||||||
data["StoragePaths"] = storageViews
|
data["StoragePaths"] = storageViews
|
||||||
|
// NAS network storage (Part A2) — separate section with the agent's per-share health (ok/idle/
|
||||||
|
// unreachable/unknown). Distinct from the physical-drive list above; no drive lifecycle actions.
|
||||||
|
data["NetworkStoragePaths"] = s.networkStorageItems(context.Background())
|
||||||
|
|
||||||
// Recovery info for emergency section
|
// Recovery info for emergency section
|
||||||
data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
|
data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
|
||||||
@@ -1210,6 +1220,12 @@ func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
|
|||||||
}
|
}
|
||||||
for _, sp := range s.settings.GetStoragePaths() {
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
if sp.Path == hddPath {
|
if sp.Path == hddPath {
|
||||||
|
// A NAS network path is NEVER "missing": its availability is the agent's per-share liveness
|
||||||
|
// (surfaced as a recoverable warning by networkStorageWarnings), NOT the drive missing/stop
|
||||||
|
// cascade. An `unreachable` NAS must not look like a removed drive.
|
||||||
|
if sp.IsNetwork() {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
if sp.Decommissioned || sp.Disconnected {
|
if sp.Decommissioned || sp.Disconnected {
|
||||||
return s.settings.GetStorageLabel(hddPath), true
|
return s.settings.GetStorageLabel(hddPath), true
|
||||||
}
|
}
|
||||||
@@ -1219,6 +1235,65 @@ func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
|
|||||||
return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone
|
return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// networkStorageWarnings returns stack-name → share label for every deployed app whose HDD_PATH is a NAS
|
||||||
|
// network path the agent currently reports `unreachable`. This is a RECOVERABLE warning ("hálózati
|
||||||
|
// tárhely nem elérhető"), explicitly NOT the drive missing/stop-cascade — the app keeps running and the
|
||||||
|
// badge clears when the NAS returns. Best-effort: an agent error or no network paths → no warnings.
|
||||||
|
func (s *Server) networkStorageWarnings(list []stacks.Stack) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
if s.settings == nil || s.stackMgr == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
netPaths := map[string]settings.StoragePath{}
|
||||||
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
|
if sp.IsNetwork() {
|
||||||
|
netPaths[sp.Path] = sp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(netPaths) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
agent, err := s.agentClient()
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
mounts, err := agent.ListNetStorage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("[WARN] [web] network storage health unavailable for warnings: %v", err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
unreachable := map[string]string{} // path → label
|
||||||
|
for _, m := range mounts {
|
||||||
|
if !m.Unreachable() { // only `unreachable` is degraded; `idle`/`ok` are benign
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := settings.NetworkMountRoot + "/" + m.Name
|
||||||
|
if sp, ok := netPaths[p]; ok {
|
||||||
|
lbl := sp.Label
|
||||||
|
if lbl == "" {
|
||||||
|
lbl = m.Name
|
||||||
|
}
|
||||||
|
unreachable[p] = lbl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(unreachable) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, st := range list {
|
||||||
|
if !st.Deployed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil {
|
||||||
|
if lbl, bad := unreachable[cfg.Env["HDD_PATH"]]; bad {
|
||||||
|
out[st.Name] = lbl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// missingStorageMap returns stack-name → storage label for every deployed app whose data drive is
|
// 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).
|
// currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator).
|
||||||
func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string {
|
func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string {
|
||||||
|
|||||||
@@ -212,6 +212,13 @@ func planDriveGates(paths []settings.StoragePath, disks []agentapi.DiskInfo) []g
|
|||||||
if sp.Decommissioned {
|
if sp.Decommissioned {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// NAS network storage is NOT a drive — the agent never lists it in /disks, so the drive-absent gate
|
||||||
|
// would falsely see it "absent" (present[sp.Path]==false) and STOP its apps. A NAS blip is
|
||||||
|
// recoverable (the agent's per-share liveness → a warning badge), never the drive stop-cascade.
|
||||||
|
// Skip network paths here entirely (Scenario C: unreachable ≠ missing).
|
||||||
|
if sp.IsNetwork() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// ONLY gate EXTERNAL drives — those registered under the stable parent /mnt/felhom-drives/<name>.
|
// ONLY gate EXTERNAL drives — those registered under the stable parent /mnt/felhom-drives/<name>.
|
||||||
// Internal SSD / system paths (e.g. /mnt/sys_drive/felhom-data) are always-present locals the agent
|
// Internal SSD / system paths (e.g. /mnt/sys_drive/felhom-data) are always-present locals the agent
|
||||||
// never reports as drives; gating them on "absence" would falsely stop/block their apps. (Legacy
|
// never reports as drives; gating them on "absence" would falsely stop/block their apps. (Legacy
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NAS network storage (Part A2). The controller is a thin proxy over the agent's /netstorage/* (A1) +
|
||||||
|
// the local StoragePath registry (Kind=network). It holds NO mount authority and NEVER persists the SMB
|
||||||
|
// password (it passes the credential straight to the agent's add request, which writes the 0600 file).
|
||||||
|
// A network share is a DISTINCT class: NO eject/decommission/migrate/wipe/SMART — remove is the only
|
||||||
|
// lifecycle action (refuseNetworkLifecycle blocks the drive ops server-side).
|
||||||
|
|
||||||
|
// defaultMediaUID/GID is the container uid/gid most media apps run as (jellyfin, *arr, immich). The
|
||||||
|
// agent applies the +100000 host offset; this is the in-guest id the share is mapped to.
|
||||||
|
const defaultMediaUID = 1000
|
||||||
|
|
||||||
|
// networkStorageItem is the UI row: the registered descriptor + live per-share health from the agent.
|
||||||
|
type networkStorageItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Server string `json:"server"`
|
||||||
|
Export string `json:"export"`
|
||||||
|
Path string `json:"path"` // the in-guest path apps point HDD_PATH at
|
||||||
|
Health string `json:"health"` // ok | idle | unreachable | unknown
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
Mounted bool `json:"mounted"`
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNetStorageAdd proxies POST /api/storage/netstorage/add → agent /netstorage/add, then registers a
|
||||||
|
// Kind=network StoragePath (no password persisted).
|
||||||
|
func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Server string `json:"server"`
|
||||||
|
Export string `json:"export"`
|
||||||
|
MappedUID int `json:"mapped_uid"`
|
||||||
|
MappedGID int `json:"mapped_gid"`
|
||||||
|
IdleTimeoutSec int `json:"idle_timeout_sec"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if !mountNameRe.MatchString(name) {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen név (csak betűk, számok, _ és -)", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proto := strings.ToLower(strings.TrimSpace(req.Protocol))
|
||||||
|
if proto != "nfs" && proto != "smb" {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "protokoll: nfs vagy smb", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server, export := strings.TrimSpace(req.Server), strings.TrimSpace(req.Export)
|
||||||
|
if server == "" || export == "" {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "a szerver és a megosztás kötelező", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if proto == "smb" && (req.Username == "" || req.Password == "") {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "SMB-hez felhasználónév és jelszó szükséges", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, gid := req.MappedUID, req.MappedGID
|
||||||
|
if uid <= 0 {
|
||||||
|
uid = defaultMediaUID
|
||||||
|
}
|
||||||
|
if gid <= 0 {
|
||||||
|
gid = defaultMediaUID
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, err := s.agentClient()
|
||||||
|
if err != nil {
|
||||||
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := agent.AddNetStorage(r.Context(), agentapi.AddNetStorageRequest{
|
||||||
|
Name: name, Protocol: proto, Server: server, Export: export,
|
||||||
|
MappedUID: uid, MappedGID: gid, IdleTimeoutSec: req.IdleTimeoutSec,
|
||||||
|
Username: req.Username, Password: req.Password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("[ERROR] [web] netstorage add %q via agent failed: %v", name, err)
|
||||||
|
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
label := strings.TrimSpace(req.Label)
|
||||||
|
if label == "" {
|
||||||
|
label = "Hálózati tárhely: " + name
|
||||||
|
}
|
||||||
|
// Register the Kind=network path. NO password is persisted — only the non-secret descriptors.
|
||||||
|
sp := settings.StoragePath{
|
||||||
|
Path: res.GuestPath,
|
||||||
|
Label: label,
|
||||||
|
Schedulable: true,
|
||||||
|
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Kind: settings.StorageKindNetwork,
|
||||||
|
Protocol: proto,
|
||||||
|
Server: server,
|
||||||
|
Export: export,
|
||||||
|
MappedUID: uid,
|
||||||
|
MappedGID: gid,
|
||||||
|
}
|
||||||
|
if err := s.settings.AddStoragePath(sp); err != nil {
|
||||||
|
s.logger.Printf("[ERROR] [web] netstorage register %q failed: %v", name, err)
|
||||||
|
writeDiskJSON(w, http.StatusInternalServerError, false, "regisztráció sikertelen", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Printf("[INFO] [web] network storage added: %s (%s %s:%s) → %s", name, proto, server, export, res.GuestPath)
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "name": name, "path": res.GuestPath})
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkStorageItems returns the registered network shares merged with the agent's live per-share
|
||||||
|
// health. A share the agent can't currently report (agent down, or not in the live list) is "unknown".
|
||||||
|
// Shared by the JSON list handler and the settings page render.
|
||||||
|
func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem {
|
||||||
|
live := map[string]agentapi.NetworkMountStatus{}
|
||||||
|
if agent, err := s.agentClient(); err == nil {
|
||||||
|
lctx, cancel := context.WithTimeout(ctx, 5*time.Second) // never let a slow agent hang the page
|
||||||
|
defer cancel()
|
||||||
|
if mounts, lerr := agent.ListNetStorage(lctx); lerr == nil {
|
||||||
|
for _, m := range mounts {
|
||||||
|
live[m.Name] = m
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.logger.Printf("[WARN] [web] netstorage live health unavailable: %v", lerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items := make([]networkStorageItem, 0)
|
||||||
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
|
if !sp.IsNetwork() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := pathBase(sp.Path)
|
||||||
|
it := networkStorageItem{
|
||||||
|
Name: name, Label: sp.Label, Protocol: sp.Protocol,
|
||||||
|
Server: sp.Server, Export: sp.Export, Path: sp.Path, Health: "unknown",
|
||||||
|
}
|
||||||
|
if m, ok := live[name]; ok {
|
||||||
|
it.Health = m.Health
|
||||||
|
it.Reachable = m.Reachable
|
||||||
|
it.Mounted = m.Mounted
|
||||||
|
it.Configured = m.Configured
|
||||||
|
}
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNetStorageList returns the registered network shares merged with the agent's live per-share health.
|
||||||
|
func (s *Server) handleNetStorageList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"network_storage": s.networkStorageItems(r.Context())})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNetStorageRemove proxies POST /api/storage/netstorage/remove → agent /netstorage/remove, then
|
||||||
|
// deregisters the path. No decommission/migrate semantics (drive-only).
|
||||||
|
func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if !mountNameRe.MatchString(name) {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen név", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agent, err := s.agentClient()
|
||||||
|
if err != nil {
|
||||||
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := agent.RemoveNetStorage(r.Context(), name); err != nil {
|
||||||
|
s.logger.Printf("[ERROR] [web] netstorage remove %q via agent failed: %v", name, err)
|
||||||
|
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where := settings.NetworkMountRoot + "/" + name
|
||||||
|
if err := s.settings.RemoveStoragePath(where); err != nil {
|
||||||
|
s.logger.Printf("[WARN] [web] netstorage deregister %q: %v", where, err)
|
||||||
|
}
|
||||||
|
s.logger.Printf("[INFO] [web] network storage removed: %s", name)
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"removed": true, "name": name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathBase returns the last path segment (the share name) of a /mnt/felhom-drives/<name> path.
|
||||||
|
func pathBase(p string) string {
|
||||||
|
p = strings.TrimRight(p, "/")
|
||||||
|
if i := strings.LastIndexByte(p, '/'); i >= 0 {
|
||||||
|
return p[i+1:]
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addNetworkPath registers a Kind=network StoragePath in the test server's settings.
|
||||||
|
func addNetworkPath(t *testing.T, s *Server, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := s.settings.AddStoragePath(settings.StoragePath{
|
||||||
|
Path: settings.NetworkMountRoot + "/" + name,
|
||||||
|
Label: "NAS " + name,
|
||||||
|
Schedulable: true,
|
||||||
|
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Kind: settings.StorageKindNetwork,
|
||||||
|
Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/" + name,
|
||||||
|
MappedUID: 1000, MappedGID: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeEnvelope(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
|
||||||
|
t.Fatalf("decode: %v (%s)", err, w.Body.String())
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNetStorage_RegisteredAndListed: a Kind=network path is registered and surfaced by the list handler
|
||||||
|
// (with health "unknown" when the agent is not reachable in the test).
|
||||||
|
func TestNetStorage_RegisteredAndListed(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
addNetworkPath(t, s, "media")
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/storage/netstorage", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleNetStorageList(w, r)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list: got %d want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, `"name":"media"`) || !strings.Contains(body, `"protocol":"nfs"`) {
|
||||||
|
t.Fatalf("network share not listed: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"health":"unknown"`) {
|
||||||
|
t.Fatalf("expected unknown health with no agent, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNetStorage_KindGate is the §10 companion: the drive-lifecycle handlers REFUSE a Kind=network path.
|
||||||
|
// A network path → 400 refusal (the Kind-gate short-circuits BEFORE any agent call). The companion
|
||||||
|
// red-proof: a DRIVE path (or an unknown path) is NOT refused by the gate — it proceeds and fails later
|
||||||
|
// with a 503 (no agent configured in the test). A Kind-blind impl would treat both alike and FAIL here.
|
||||||
|
func TestNetStorage_KindGate(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
addNetworkPath(t, s, "media")
|
||||||
|
|
||||||
|
lifecycle := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
handler func(http.ResponseWriter, *http.Request)
|
||||||
|
driveCompan bool // a drive path fails cleanly at the agent step (no stackMgr needed) → safe to assert
|
||||||
|
}{
|
||||||
|
{"eject", "/api/storage/eject", s.handleStorageEject, true},
|
||||||
|
{"wipe", "/api/storage/wipe", s.handleStorageWipe, true},
|
||||||
|
{"decommission", "/api/storage/decommission", s.handleStorageDecommission, false},
|
||||||
|
}
|
||||||
|
for _, lc := range lifecycle {
|
||||||
|
// A NETWORK path is refused by the Kind-gate (400, before any agent/stackMgr use).
|
||||||
|
netBody := `{"where":"/mnt/felhom-drives/media","device":"/dev/sdz","mount_name":"media","mode":"anyway"}`
|
||||||
|
rn := httptest.NewRequest(http.MethodPost, lc.path, strings.NewReader(netBody))
|
||||||
|
wn := httptest.NewRecorder()
|
||||||
|
lc.handler(wn, rn)
|
||||||
|
if wn.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("%s on a network path: got %d want 400 (Kind-gate must refuse)", lc.name, wn.Code)
|
||||||
|
}
|
||||||
|
if env := decodeEnvelope(t, wn); env["ok"] != false {
|
||||||
|
t.Fatalf("%s on a network path must be ok:false, got %v", lc.name, env["ok"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// COMPANION: a DRIVE path is NOT refused by the Kind-gate — it falls through to the agent step and
|
||||||
|
// fails 503 (no agent), proving the gate keys on Kind, not the path. (Only for handlers that reach
|
||||||
|
// the agent before any nil-dependency; decommission-anyway dives into stackMgr, covered by the
|
||||||
|
// direct refuseNetworkLifecycle unit test instead.)
|
||||||
|
if !lc.driveCompan {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
drvBody := `{"where":"/mnt/hdd_1","device":"/dev/sdz","mount_name":"hdd_1","mode":"anyway"}`
|
||||||
|
rd := httptest.NewRequest(http.MethodPost, lc.path, strings.NewReader(drvBody))
|
||||||
|
wd := httptest.NewRecorder()
|
||||||
|
lc.handler(wd, rd)
|
||||||
|
if wd.Code == http.StatusBadRequest {
|
||||||
|
t.Fatalf("%s on a DRIVE path must NOT hit the Kind-gate 400 (got 400) — the gate would be Kind-blind", lc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNetStorage_UnreachableNotMissing is the §10 health-surface companion: an `unreachable` NAS must
|
||||||
|
// surface as a recoverable WARNING, never the drive "missing → stopped" cascade. Here: (1) a network path
|
||||||
|
// is NEVER reported "missing" by missingStorageLabel, and (2) the drive-absent gate produces NO Stop
|
||||||
|
// action for a network path even though it is absent from the agent's /disks list. The companion shows a
|
||||||
|
// real DRIVE path under the same parent, absent from /disks, DOES get a Stop action — proving the network
|
||||||
|
// exemption (not luck) is what prevents the cascade. An impl that mapped unreachable→missing FAILS (1).
|
||||||
|
func TestNetStorage_UnreachableNotMissing(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
addNetworkPath(t, s, "media") // registered network path /mnt/felhom-drives/media
|
||||||
|
|
||||||
|
// (1) a network path is never "missing" (no stop-cascade label), regardless of agent reachability.
|
||||||
|
if lbl, missing := s.missingStorageLabel("/mnt/felhom-drives/media"); missing {
|
||||||
|
t.Fatalf("a network path must NOT be 'missing' (got missing=true, label=%q)", lbl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) the drive-absent gate skips the network path: with the agent reporting NO disks (the NAS is not
|
||||||
|
// a drive — Scenario D), the network path is absent from /disks but must produce NO Stop action.
|
||||||
|
netPaths := s.settings.GetStoragePaths()
|
||||||
|
if acts := planDriveGates(netPaths, nil); len(acts) != 0 {
|
||||||
|
t.Fatalf("the drive gate must produce NO action for a network path, got %+v", acts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// COMPANION: a real DRIVE path under the same parent, absent from /disks, DOES get a Stop action —
|
||||||
|
// proving the gate WOULD have stopped the network path's apps if it weren't exempted.
|
||||||
|
drivePaths := []settings.StoragePath{{Path: StableParentDir + "/realhdd", Schedulable: true}}
|
||||||
|
acts := planDriveGates(drivePaths, nil)
|
||||||
|
if len(acts) != 1 || !acts[0].Stop {
|
||||||
|
t.Fatalf("control: an absent DRIVE path under the parent must get a Stop action, got %+v", acts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefuseNetworkLifecycle is the unit-level discriminator: network → refused, drive/unknown → not.
|
||||||
|
func TestRefuseNetworkLifecycle(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
addNetworkPath(t, s, "media")
|
||||||
|
|
||||||
|
w1 := httptest.NewRecorder()
|
||||||
|
if !s.refuseNetworkLifecycle(w1, "/mnt/felhom-drives/media") {
|
||||||
|
t.Fatal("a network path must be refused")
|
||||||
|
}
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
if s.refuseNetworkLifecycle(w2, "/mnt/hdd_1") {
|
||||||
|
t.Fatal("a drive/unknown path must NOT be refused by the network gate")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,6 +217,19 @@ func (s *Server) registerStoragePath(where, label string, setDefault bool) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refuseNetworkLifecycle blocks a drive-lifecycle action (eject/decommission/migrate/wipe) on a NAS
|
||||||
|
// network-storage path. A network share is a DISTINCT class with no device lifecycle — its only
|
||||||
|
// lifecycle action is /api/netstorage/remove. Returns true (and writes the refusal) when the path is a
|
||||||
|
// network path; the caller must then return. This is the server-side Kind-gate that backs the UI gating.
|
||||||
|
func (s *Server) refuseNetworkLifecycle(w http.ResponseWriter, where string) bool {
|
||||||
|
if s.settings != nil && s.settings.IsNetworkStoragePath(where) {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false,
|
||||||
|
"ez hálózati tárhely (NAS) — a meghajtó-műveletek (leválasztás/leszerelés/áthelyezés/törlés) nem alkalmazhatók rá; használd az „Eltávolítás” gombot", nil)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ---- HTTP handlers (behind RequireAuth + CsrfProtect) -----------------------------------------
|
// ---- HTTP handlers (behind RequireAuth + CsrfProtect) -----------------------------------------
|
||||||
|
|
||||||
// storageWizardPageHandler renders the init/attach wizard page (the disk list + actions are driven
|
// storageWizardPageHandler renders the init/attach wizard page (the disk list + actions are driven
|
||||||
@@ -253,6 +266,13 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.handleStorageMigrateStatus(w, r)
|
s.handleStorageMigrateStatus(w, r)
|
||||||
case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost:
|
||||||
s.handleStorageDecommission(w, r)
|
s.handleStorageDecommission(w, r)
|
||||||
|
// NAS network storage (Part A2) — distinct from the drive lifecycle above (proxy to agent /netstorage/*).
|
||||||
|
case r.URL.Path == "/api/storage/netstorage/add" && r.Method == http.MethodPost:
|
||||||
|
s.handleNetStorageAdd(w, r)
|
||||||
|
case r.URL.Path == "/api/storage/netstorage" && r.Method == http.MethodGet:
|
||||||
|
s.handleNetStorageList(w, r)
|
||||||
|
case r.URL.Path == "/api/storage/netstorage/remove" && r.Method == http.MethodPost:
|
||||||
|
s.handleNetStorageRemove(w, r)
|
||||||
case r.URL.Path == "/api/storage/disconnect" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/disconnect" && r.Method == http.MethodPost:
|
||||||
s.handleStorageDisconnect(w, r)
|
s.handleStorageDisconnect(w, r)
|
||||||
case r.URL.Path == "/api/storage/reconnect" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/reconnect" && r.Method == http.MethodPost:
|
||||||
@@ -276,6 +296,9 @@ func (s *Server) handleStorageMigrate(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Source)) || s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
id, err := s.stackMgr.MigrateAll(r.Context(), strings.TrimSpace(req.Source), strings.TrimSpace(req.Target))
|
id, err := s.stackMgr.MigrateAll(r.Context(), strings.TrimSpace(req.Source), strings.TrimSpace(req.Target))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
|
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
|
||||||
@@ -328,6 +351,9 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
|
|||||||
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if s.refuseNetworkLifecycle(w, req.Where) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch req.Mode {
|
switch req.Mode {
|
||||||
case "migrate":
|
case "migrate":
|
||||||
@@ -554,6 +580,9 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if s.refuseNetworkLifecycle(w, req.Where) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
|
// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
|
||||||
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
|
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)
|
writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
|
||||||
@@ -716,6 +745,9 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if s.refuseNetworkLifecycle(w, req.Where) {
|
||||||
|
return
|
||||||
|
}
|
||||||
agent, err := s.agentClient()
|
agent, err := s.agentClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||||
|
|||||||
@@ -158,6 +158,7 @@
|
|||||||
<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}}
|
{{$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}}
|
||||||
|
{{$nw := index $.NetworkWarnings .Name}}{{if $nw}}<span class="badge badge-warn" title="A hálózati tárhely (NAS) jelenleg nem érhető el. Az alkalmazás fut; az adatok elérése a NAS visszatértével helyreáll.">⚠ Hálózati tárhely nem elérhető: {{$nw}}</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}}
|
||||||
|
|||||||
@@ -372,6 +372,105 @@ pollUntilBack();
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- NAS network storage (Part A2) — a distinct class from the physical drives above. No
|
||||||
|
leválasztás/leszerelés/áthelyezés/törlés; the only lifecycle action is Eltávolítás. -->
|
||||||
|
<div class="storage-section" style="margin-top:1.5rem">
|
||||||
|
<h3 style="margin-bottom:.25rem">Hálózati tárhely (NAS)</h3>
|
||||||
|
<p class="form-hint" style="margin-top:0">
|
||||||
|
Egy NAS-megosztás (NFS vagy SMB) csatlakoztatása nagy méretű médiatartalomhoz (film, fotó, zene).
|
||||||
|
A megosztás kiválasztható médiaalkalmazás adatkönyvtáraként. A hálózati tárhely nem fizikai
|
||||||
|
meghajtó — nincs leszerelés/áthelyezés, csak eltávolítás.
|
||||||
|
</p>
|
||||||
|
{{if .NetworkStoragePaths}}
|
||||||
|
<div class="storage-paths-list">
|
||||||
|
{{range .NetworkStoragePaths}}
|
||||||
|
<div class="storage-path-item{{if eq .Health "unreachable"}} storage-disconnected{{end}}">
|
||||||
|
<div class="storage-path-header">
|
||||||
|
<div class="storage-path-info">
|
||||||
|
<span class="storage-path-label">{{.Label}}</span>
|
||||||
|
<span class="storage-path-path mono">{{.Protocol}} · {{.Server}}:{{.Export}} → {{.Path}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="storage-path-badges">
|
||||||
|
{{if eq .Health "ok"}}<span class="badge badge-ok" title="A megosztás elérhető és csatlakoztatva van">Elérhető</span>
|
||||||
|
{{else if eq .Health "idle"}}<span class="badge badge-neutral" title="Elérhető, jelenleg készenlétben (igény szerint csatlakozik)">Készenlét</span>
|
||||||
|
{{else if eq .Health "unreachable"}}<span class="badge badge-warn" title="A NAS jelenleg nem érhető el — az érintett alkalmazások átmenetileg nem olvasnak róla">Nem elérhető</span>
|
||||||
|
{{else}}<span class="badge badge-neutral" title="Az állapot jelenleg nem lekérdezhető">Ismeretlen</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="storage-path-actions">
|
||||||
|
<button class="btn btn-xs btn-danger-outline" onclick="netStorageRemove('{{.Name}}','{{.Label}}')">Eltávolítás</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="form-hint">Nincs hálózati tárhely beállítva.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<details style="margin-top:.75rem">
|
||||||
|
<summary class="btn btn-xs btn-primary" style="cursor:pointer;display:inline-block">Hálózati tárhely hozzáadása</summary>
|
||||||
|
<div style="margin-top:.75rem;padding:1rem;border:1px solid var(--border,#ddd);border-radius:6px;max-width:520px">
|
||||||
|
<div class="form-row"><label>Név (azonosító)</label>
|
||||||
|
<input id="ns-name" type="text" placeholder="pl. media" class="form-input"></div>
|
||||||
|
<div class="form-row"><label>Protokoll</label>
|
||||||
|
<select id="ns-protocol" class="form-input" onchange="nsToggleSmb()">
|
||||||
|
<option value="nfs">NFS (ajánlott)</option>
|
||||||
|
<option value="smb">SMB / CIFS</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="form-row"><label>Szerver (IP vagy hosztnév)</label>
|
||||||
|
<input id="ns-server" type="text" placeholder="pl. 192.168.0.10" class="form-input"></div>
|
||||||
|
<div class="form-row"><label id="ns-export-label">Megosztás (NFS export útvonal)</label>
|
||||||
|
<input id="ns-export" type="text" placeholder="pl. /volume1/media" class="form-input"></div>
|
||||||
|
<div class="form-row"><label>Alkalmazás felhasználói azonosító (uid)</label>
|
||||||
|
<input id="ns-uid" type="number" value="1000" class="form-input">
|
||||||
|
<span class="form-hint">A legtöbb médiaalkalmazás 1000-es uid-del fut.</span></div>
|
||||||
|
<div id="ns-smb-creds" style="display:none">
|
||||||
|
<div class="form-row"><label>SMB felhasználónév</label>
|
||||||
|
<input id="ns-username" type="text" autocomplete="off" class="form-input"></div>
|
||||||
|
<div class="form-row"><label>SMB jelszó</label>
|
||||||
|
<input id="ns-password" type="password" autocomplete="new-password" class="form-input">
|
||||||
|
<span class="form-hint">A jelszót a gazda ügynök 0600-as fájlba írja; a vezérlő nem tárolja.</span></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary" style="margin-top:.5rem" onclick="netStorageAdd()">Csatlakoztatás</button>
|
||||||
|
<div id="ns-add-msg" class="form-hint" style="margin-top:.5rem"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function nsToggleSmb(){
|
||||||
|
var smb = document.getElementById('ns-protocol').value === 'smb';
|
||||||
|
document.getElementById('ns-smb-creds').style.display = smb ? 'block' : 'none';
|
||||||
|
document.getElementById('ns-export-label').textContent = smb ? 'Megosztás (SMB megosztásnév)' : 'Megosztás (NFS export útvonal)';
|
||||||
|
document.getElementById('ns-export').placeholder = smb ? 'pl. media' : 'pl. /volume1/media';
|
||||||
|
}
|
||||||
|
function netStorageAdd(){
|
||||||
|
var msg = document.getElementById('ns-add-msg');
|
||||||
|
var body = {
|
||||||
|
name: (document.getElementById('ns-name').value||'').trim(),
|
||||||
|
protocol: document.getElementById('ns-protocol').value,
|
||||||
|
server: (document.getElementById('ns-server').value||'').trim(),
|
||||||
|
export: (document.getElementById('ns-export').value||'').trim(),
|
||||||
|
mapped_uid: parseInt(document.getElementById('ns-uid').value||'1000',10),
|
||||||
|
mapped_gid: parseInt(document.getElementById('ns-uid').value||'1000',10),
|
||||||
|
username: (document.getElementById('ns-username').value||''),
|
||||||
|
password: (document.getElementById('ns-password').value||'')
|
||||||
|
};
|
||||||
|
msg.textContent = 'Csatlakoztatás folyamatban…';
|
||||||
|
fetch('/api/storage/netstorage/add',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)})
|
||||||
|
.then(function(r){return r.json();}).then(function(d){
|
||||||
|
if(d.ok){ msg.textContent='Sikeres ✓'; setTimeout(function(){location.reload();},900); }
|
||||||
|
else { msg.textContent='Hiba: '+(d.error||'ismeretlen'); }
|
||||||
|
}).catch(function(e){ msg.textContent='Hiba: '+e; });
|
||||||
|
}
|
||||||
|
function netStorageRemove(name,label){
|
||||||
|
if(!confirm('Biztosan eltávolítja a(z) '+label+' hálózati tárhelyet?\n\nA megosztás leválasztásra kerül; a NAS-on lévő adatok érintetlenek maradnak.')) return;
|
||||||
|
fetch('/api/storage/netstorage/remove',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({name:name})})
|
||||||
|
.then(function(r){return r.json();}).then(function(d){
|
||||||
|
if(d.ok){ location.reload(); } else { alert('Hiba: '+(d.error||'ismeretlen')); }
|
||||||
|
}).catch(function(e){ alert('Hiba: '+e); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<div id="migrate-progress" style="display:none;margin-top:1rem;padding:1rem;border:1px solid var(--accent);border-radius:6px;background:rgba(0,136,204,0.06)">
|
<div id="migrate-progress" style="display:none;margin-top:1rem;padding:1rem;border:1px solid var(--accent);border-radius:6px;background:rgba(0,136,204,0.06)">
|
||||||
<strong>Adatok áthelyezése</strong>
|
<strong>Adatok áthelyezése</strong>
|
||||||
<div id="migrate-progress-body" style="margin-top:.5rem">…</div>
|
<div id="migrate-progress-body" style="margin-top:.5rem">…</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<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}}
|
{{$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}}
|
||||||
|
{{$nw := index $.NetworkWarnings .Name}}{{if $nw}}<span class="badge badge-warn" title="A hálózati tárhely (NAS) jelenleg nem érhető el. Az alkalmazás fut; az adatok elérése a NAS visszatértével helyreáll.">⚠ Hálózati tárhely nem elérhető: {{$nw}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .Meta.Description}}
|
{{if .Meta.Description}}
|
||||||
|
|||||||
@@ -3146,6 +3146,7 @@ a.stat-card:hover {
|
|||||||
|
|
||||||
/* badges missing from the global sheet */
|
/* badges missing from the global sheet */
|
||||||
.badge-ok { background: rgba(35, 134, 54, 0.18); color: #3fb950; }
|
.badge-ok { background: rgba(35, 134, 54, 0.18); color: #3fb950; }
|
||||||
|
.badge-neutral { background: rgba(139, 148, 158, 0.18); color: #8b949e; }
|
||||||
.badge-lock { background: rgba(210, 153, 34, 0.18); color: var(--yellow); }
|
.badge-lock { background: rgba(210, 153, 34, 0.18); color: var(--yellow); }
|
||||||
.badge-muted { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); }
|
.badge-muted { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); }
|
||||||
.badge-info { background: rgba(0, 136, 204, 0.18); color: var(--accent-light); }
|
.badge-info { background: rgba(0, 136, 204, 0.18); color: var(--accent-light); }
|
||||||
|
|||||||
Reference in New Issue
Block a user