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:
@@ -509,6 +509,92 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirme
|
||||
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
|
||||
// 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).
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
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"
|
||||
IsDefault bool `json:"is_default,omitempty"` // new apps use this by default
|
||||
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
|
||||
DecommissionedAt string `json:"decommissioned_at,omitempty"` // RFC3339 timestamp
|
||||
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.
|
||||
type NotificationPrefs struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
@@ -846,6 +874,20 @@ func (s *Settings) IsStoragePathKnown(path string) bool {
|
||||
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,
|
||||
// schedulable (active) storage path. Returns false if the path is unknown,
|
||||
// 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["Stacks"] = deployedStacks
|
||||
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
|
||||
data["NetworkWarnings"] = s.networkStorageWarnings(deployedStacks) // NAS unreachable — recoverable warning, not "missing"
|
||||
data["RunningCount"] = running
|
||||
data["StoppedCount"] = stopped
|
||||
data["TotalCount"] = len(stackList)
|
||||
@@ -202,6 +203,7 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
allStacks := s.stackMgr.GetStacks()
|
||||
data["Stacks"] = allStacks
|
||||
data["MissingStorage"] = s.missingStorageMap(allStacks)
|
||||
data["NetworkWarnings"] = s.networkStorageWarnings(allStacks) // NAS unreachable — recoverable warning, not "missing"
|
||||
|
||||
// Build storage label lookup for deployed apps
|
||||
storageLabels := make(map[string]string) // stack name → storage label
|
||||
@@ -915,6 +917,11 @@ func (s *Server) settingsData() map[string]interface{} {
|
||||
}
|
||||
var storageViews []StoragePathView
|
||||
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{
|
||||
StoragePath: sp,
|
||||
StoppedApps: sp.StoppedStacks,
|
||||
@@ -942,6 +949,9 @@ func (s *Server) settingsData() map[string]interface{} {
|
||||
storageViews = append(storageViews, view)
|
||||
}
|
||||
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
|
||||
data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
|
||||
@@ -1210,6 +1220,12 @@ func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
|
||||
}
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator).
|
||||
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 {
|
||||
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>.
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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) -----------------------------------------
|
||||
|
||||
// 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)
|
||||
case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost:
|
||||
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:
|
||||
s.handleStorageDisconnect(w, r)
|
||||
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)
|
||||
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))
|
||||
if err != 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)
|
||||
return
|
||||
}
|
||||
if s.refuseNetworkLifecycle(w, req.Where) {
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Mode {
|
||||
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)
|
||||
return
|
||||
}
|
||||
if s.refuseNetworkLifecycle(w, req.Where) {
|
||||
return
|
||||
}
|
||||
// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
|
||||
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "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)
|
||||
return
|
||||
}
|
||||
if s.refuseNetworkLifecycle(w, req.Where) {
|
||||
return
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
<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}}
|
||||
{{$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 .Protected}}
|
||||
|
||||
@@ -372,6 +372,105 @@ pollUntilBack();
|
||||
{{end}}
|
||||
</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)">
|
||||
<strong>Adatok áthelyezése</strong>
|
||||
<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>
|
||||
{{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}}
|
||||
{{$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>
|
||||
|
||||
{{if .Meta.Description}}
|
||||
|
||||
@@ -3146,6 +3146,7 @@ a.stat-card:hover {
|
||||
|
||||
/* badges missing from the global sheet */
|
||||
.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-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); }
|
||||
|
||||
Reference in New Issue
Block a user