Media build part 2 (unreleased): drive wiring in every app (R-1024/R-1037), old-install repair (R-1047), drive events notice + real restart now/later + stream check, file browser lets go of dead/removed drives and recreates on return (R-1038), safe removal keeps the drive (R-1044), Plex claim on the running server + probe accept (R-1028/R-1045)
gates / gates (push) Successful in 45s
gates / gates (push) Successful in 45s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -782,6 +782,8 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
// v0.280.0 (decision 46): the setup gate's card, while the gate stands.
|
||||
data["SetupGateClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.SetupGate.Closed()
|
||||
data["SetupGateHasProbe"] = found.Meta.SetupDoneProbe != nil && found.Meta.SetupDoneProbe.URL != ""
|
||||
// R-1028 (v0.313.0): Plex is claimed on its running server — the household pastes the code here, after the install.
|
||||
data["PlexClaim"] = data["SetupGateClosed"] == true && found.Meta.Slug == "plex"
|
||||
// R-741: the install hold's card, while the app is held for its first-login change.
|
||||
data["InstallHoldClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.InstallHold.Closed()
|
||||
// v0.282.0 (decision 49): "close sign-up now" on an app installed before the rule; the app's own switch.
|
||||
@@ -3478,6 +3480,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
s.logger.Printf("[INFO] [web] Storage path added: %s (%s)", path, label)
|
||||
go s.SyncFileBrowserMounts()
|
||||
s.mediaDriveEvent(path, "added")
|
||||
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_added", path)), http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -3596,6 +3599,14 @@ func (s *Server) SyncFileBrowserMounts() {
|
||||
s.syncFileBrowserMounts(false)
|
||||
}
|
||||
|
||||
// SyncFileBrowserMountsForce syncs AND recreates the file browser even when nothing changed (R-1038: a returned drive).
|
||||
func (s *Server) SyncFileBrowserMountsForce() {
|
||||
s.fbForceMu.Lock()
|
||||
s.fbForce = true
|
||||
s.fbForceMu.Unlock()
|
||||
s.syncFileBrowserMounts(false)
|
||||
}
|
||||
|
||||
// SyncFileBrowserMountsReset is like SyncFileBrowserMounts but resets the FileBrowser
|
||||
// database when sources change. Use only after restore — normal operations should use
|
||||
// SyncFileBrowserMounts to preserve user accounts, permissions, and share links.
|
||||
@@ -3673,6 +3684,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
// no mount behind it renders a broken sidebar entry).
|
||||
storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{
|
||||
isMount: system.IsMountPoint,
|
||||
isDead: system.MountIsShutdown,
|
||||
classify: s.classifyFSPath,
|
||||
ensureSkeleton: s.ensureUserdataSkeleton,
|
||||
logger: s.logger,
|
||||
@@ -3748,6 +3760,16 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
finalConfig, _ := os.ReadFile(configPath)
|
||||
finalCompose, _ := os.ReadFile(composePath)
|
||||
changed := fbNeedsRecreate(oldConfig, finalConfig, oldCompose, finalCompose)
|
||||
// R-1038 (v0.313.0): a drive that CAME BACK at the same path changes neither file, yet the running file browser
|
||||
// still holds the dead mount (I/O error 4 minutes after the return, measured). A forced sync recreates it.
|
||||
s.fbForceMu.Lock()
|
||||
if s.fbForce {
|
||||
if !changed {
|
||||
s.logger.Printf("[INFO] [web] FileBrowser: recreating although nothing changed — a drive came back (R-1038)")
|
||||
}
|
||||
changed, s.fbForce = true, false
|
||||
}
|
||||
s.fbForceMu.Unlock()
|
||||
|
||||
// If sources changed and caller requested a DB reset (restore flow),
|
||||
// nuke the data volume so FileBrowser re-reads config.yaml from scratch.
|
||||
@@ -3791,6 +3813,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
// without a drive, a NAS or docker.
|
||||
type fbPathDeps struct {
|
||||
isMount func(string) bool // drive-absent gate probe (production: system.IsMountPoint)
|
||||
isDead func(string) bool // a dead filesystem at the path (production: system.MountIsShutdown); nil = never
|
||||
classify func(string) string // network stub gate (production: Server.classifyFSPath; nil → include, fail open)
|
||||
ensureSkeleton func(string) error // userdata skeleton (production: appbackup.EnsureUserdataSkeleton) — DRIVES ONLY
|
||||
// nsRootFor resolves a bare DRIVE path to its felhom-data namespace root (R-203). Injected rather
|
||||
@@ -3846,6 +3869,14 @@ func buildFileBrowserPaths(paths []settings.StoragePath, d fbPathDeps) (storageM
|
||||
}
|
||||
continue
|
||||
}
|
||||
// R-1038/R-1044 (v0.313.0): a DEAD drive (pulled: `shutdown` in mountinfo — no disk I/O) and a SAFELY REMOVED
|
||||
// one are not bound either: the file browser lets go of them, and binds the drive again when it is back.
|
||||
if sp.EjectedAt != "" || (d.isDead != nil && d.isDead(sp.Path)) {
|
||||
if d.logger != nil {
|
||||
d.logger.Printf("[INFO] [web] FileBrowser: drive %s is removed or dead — not bound this pass", sp.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer browses ONLY userdata —
|
||||
// app internals (appdata/) and the recovery units + Tier 2 copies (backups/) are NOT mounted
|
||||
// into FileBrowser. userdata is owned group 1000 mode 2775 (setgid), and FileBrowser runs as
|
||||
|
||||
@@ -371,6 +371,19 @@ func i18nCases() []i18nCase {
|
||||
}}
|
||||
}
|
||||
base = append(base, gateCase("app_info_setup_gate_button", false), gateCase("app_info_setup_gate_probe", true))
|
||||
// v0.313.0 (R-1028): the Plex claim box inside a closed Plex gate — captured when born.
|
||||
base = append(base, i18nCase{"app_info_plex_claim", "app_info", func() map[string]interface{} {
|
||||
d := i18nLayoutData("stacks", "Plex")
|
||||
st := stacks.Stack{Name: "plex", Deployed: true, State: stacks.StateRunning}
|
||||
st.Meta = stacks.Metadata{DisplayName: "Plex", Slug: "plex"}
|
||||
d["Stack"] = st
|
||||
d["Meta"] = st.Meta
|
||||
d["AppInfo"] = st.Meta.AppInfo
|
||||
d["SetupGateClosed"] = true
|
||||
d["SetupGateHasProbe"] = true
|
||||
d["PlexClaim"] = true
|
||||
return d
|
||||
}})
|
||||
// R-741: the install hold's card, while an after_install app waits for its first-login change.
|
||||
base = append(base, i18nCase{"app_info_install_hold", "app_info", func() map[string]interface{} {
|
||||
d := i18nLayoutData("stacks", "Calibre")
|
||||
|
||||
@@ -308,6 +308,7 @@ func (s *Server) ReconcileDriveGates() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.reconcileEjectedDrives(ctx, agent, resp.Disks)
|
||||
for _, a := range planDriveGates(s.settings.GetStoragePaths(), resp.Disks) {
|
||||
switch {
|
||||
case a.Stop:
|
||||
@@ -325,6 +326,7 @@ func (s *Server) ReconcileDriveGates() {
|
||||
// E-2 Part 5: when the absent drive is the BACKUP TARGET, that is the more specific and more
|
||||
// urgent fact, so it gets its own event rather than being folded into the generic one.
|
||||
s.notifyDriveAbsent(a.Path, stopped, driveTargetByPath(resp.Disks))
|
||||
s.mediaDriveEvent(a.Path, "removed")
|
||||
go s.SyncFileBrowserMounts()
|
||||
case a.Return:
|
||||
if a.Raw != "" {
|
||||
@@ -346,7 +348,9 @@ func (s *Server) ReconcileDriveGates() {
|
||||
// E-2b: the recovery half. An operator told a drive vanished must be told it came back —
|
||||
// otherwise the alarm is a dead end and the next one is trusted less.
|
||||
s.notifyDriveReturned(a.Path, driveTargetByPath(resp.Disks))
|
||||
go s.SyncFileBrowserMounts()
|
||||
s.mediaDriveEvent(a.Path, "returned")
|
||||
// R-1038: same path, same files → nothing „changed", yet the file browser holds the DEAD mount. Recreate.
|
||||
go s.SyncFileBrowserMountsForce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/mediawire"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// ── Media drive events (v0.313.0; `09` §3 decisions 238/239/245; felhom.eu `architecture/15-media-library.md` §10) ──
|
||||
//
|
||||
// On a drive ADDED, REMOVED or RETURNED the media apps must restart to see it (Docker resolves binds at container
|
||||
// start). The box records the event on the drive (settings: MediaChangedAt), and the storage page and the start page say
|
||||
// which apps must restart, with „restart now" and „later" (tonight 03:30). Each drive card says whether the apps were
|
||||
// restarted since the drive changed. „Restart now" first counts what Plex and Jellyfin are playing (a restart cuts a
|
||||
// stream — K5-4) and asks once more when something plays. After the restart the box wires every present drive into the
|
||||
// apps (mediawire, R-1024). A RETURNED drive also recreates the file browser at once (R-1038 — it holds no stream).
|
||||
//
|
||||
// A SAFE REMOVAL (R-1044) pauses only that drive's torrents, recreates the media apps and the file browser WITHOUT the
|
||||
// drive, then unmounts — and keeps the drive registered, so its return needs no household step; the paused torrents
|
||||
// resume when it is back. The dead-mount rule holds throughout: no path here ever makes an empty folder under an app.
|
||||
|
||||
const mediaLaterHour, mediaLaterMinute = 3, 30
|
||||
|
||||
func (s *Server) wirer() *mediawire.Wirer {
|
||||
s.mediaMu.Lock()
|
||||
defer s.mediaMu.Unlock()
|
||||
if s.mediaWirer == nil {
|
||||
s.mediaWirer = mediawire.New(mediawire.DockerBox{}, s.logger)
|
||||
}
|
||||
return s.mediaWirer
|
||||
}
|
||||
|
||||
func (s *Server) mediaApps() []string {
|
||||
if s.stackMgr == nil {
|
||||
return nil
|
||||
}
|
||||
return s.stackMgr.MediaApps()
|
||||
}
|
||||
|
||||
func (s *Server) presentMediaDrives() []mediawire.Drive {
|
||||
if s.stackMgr == nil {
|
||||
return nil
|
||||
}
|
||||
var out []mediawire.Drive
|
||||
for _, p := range s.stackMgr.PresentMediaDrives() {
|
||||
out = append(out, mediawire.Drive{Path: p})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RunMediaWiring wires every present drive into the media apps and keeps the result for the page.
|
||||
func (s *Server) RunMediaWiring(reason string) []mediawire.Result {
|
||||
if len(s.mediaApps()) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.logger.Printf("[INFO] [media] wiring the drives into the media apps (%s)", reason)
|
||||
res := s.wirer().Run(s.presentMediaDrives())
|
||||
s.mediaMu.Lock()
|
||||
s.mediaWiring, s.mediaWiredAt = res, time.Now()
|
||||
s.mediaMu.Unlock()
|
||||
return res
|
||||
}
|
||||
|
||||
// RunMediaWiringSoon debounces the wiring after media apps come up: one run 60 s after the LAST call (an install and
|
||||
// a restart of six apps make one run, not six), once more 2 min later if an app answered with an error.
|
||||
func (s *Server) RunMediaWiringSoon(app string) {
|
||||
s.mediaMu.Lock()
|
||||
if s.mediaSoonTmr != nil {
|
||||
s.mediaSoonTmr.Stop()
|
||||
}
|
||||
s.mediaSoonTmr = time.AfterFunc(60*time.Second, func() {
|
||||
for _, r := range s.RunMediaWiring("a media app came up: " + app) {
|
||||
if r.State == "error" {
|
||||
time.Sleep(2 * time.Minute)
|
||||
s.RunMediaWiring("retry after an app error")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
s.mediaMu.Unlock()
|
||||
}
|
||||
|
||||
// mediaDriveEvent records a drive added / removed / returned — only when a media app exists to care.
|
||||
func (s *Server) mediaDriveEvent(path, change string) {
|
||||
if s.settings == nil || len(s.mediaApps()) == 0 {
|
||||
return
|
||||
}
|
||||
if err := s.settings.MarkMediaChanged(path, change); err != nil {
|
||||
s.logger.Printf("[WARN] [media] recording the drive event %s %s: %v", change, path, err)
|
||||
return
|
||||
}
|
||||
if s.stackMgr != nil {
|
||||
// Write the new per-drive binds now (no restart): the page can say exactly which apps changed.
|
||||
if changed := s.stackMgr.MediaAppsNeedingRestart(); len(changed) > 0 {
|
||||
s.logger.Printf("[INFO] [media] drive %s %s: the binds of %v changed — they see it after a restart", path, change, changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// staleMediaApps lists running media apps (and the file browser) whose own mount table still holds a DEAD drive
|
||||
// (`shutdown` — K4: each container's mountinfo shows the old device after a return). No disk I/O.
|
||||
func (s *Server) staleMediaApps() []string {
|
||||
var out []string
|
||||
for _, c := range append(s.mediaApps(), "filebrowser") {
|
||||
b, err := dockerexec.Command("docker", "exec", c, "cat", "/proc/self/mountinfo").Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 5 || !strings.HasPrefix(f[4], StableParentDir+"/") {
|
||||
continue
|
||||
}
|
||||
if i := strings.Index(line, " - "); i >= 0 {
|
||||
if t := strings.Fields(line[i+3:]); len(t) >= 3 && strings.Contains(","+t[2]+",", ",shutdown,") {
|
||||
out = append(out, c)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MediaDriveNotice is what the storage page and the start page show.
|
||||
type MediaDriveNotice struct {
|
||||
Pending bool `json:"pending"`
|
||||
Drives []MediaNoticeDrive `json:"drives"`
|
||||
Apps []string `json:"apps"`
|
||||
Stale []string `json:"stale,omitempty"`
|
||||
LaterAt string `json:"later_at,omitempty"`
|
||||
Wiring []mediawire.Result `json:"wiring,omitempty"`
|
||||
WiredAt string `json:"wired_at,omitempty"`
|
||||
}
|
||||
|
||||
// MediaNoticeDrive is one drive's line.
|
||||
type MediaNoticeDrive struct {
|
||||
Path string `json:"path"`
|
||||
Label string `json:"label"`
|
||||
Change string `json:"change"`
|
||||
ChangedAt string `json:"changed_at"`
|
||||
Restarted bool `json:"restarted"` // „apps restarted since this drive changed"
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
}
|
||||
|
||||
// MediaNotice computes the notice. Cheap enough for a page render: settings + one `docker exec cat` per media app.
|
||||
func (s *Server) MediaNotice(checkStale bool) MediaDriveNotice {
|
||||
n := MediaDriveNotice{Apps: s.mediaApps()}
|
||||
if s.settings == nil || len(n.Apps) == 0 {
|
||||
return n
|
||||
}
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.MediaChangedAt == "" {
|
||||
continue
|
||||
}
|
||||
d := MediaNoticeDrive{Path: sp.Path, Label: sp.Label, Change: sp.MediaChange, ChangedAt: sp.MediaChangedAt,
|
||||
Restarted: !sp.MediaRestartPending(), Removed: sp.EjectedAt != ""}
|
||||
if d.Label == "" {
|
||||
d.Label = filepath.Base(sp.Path)
|
||||
}
|
||||
n.Pending = n.Pending || !d.Restarted
|
||||
n.Drives = append(n.Drives, d)
|
||||
}
|
||||
sort.Slice(n.Drives, func(i, j int) bool { return n.Drives[i].Path < n.Drives[j].Path })
|
||||
if checkStale {
|
||||
n.Stale = s.staleMediaApps()
|
||||
n.Pending = n.Pending || len(n.Stale) > 0
|
||||
}
|
||||
s.mediaMu.Lock()
|
||||
if !s.mediaLaterAt.IsZero() {
|
||||
n.LaterAt = s.mediaLaterAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
n.Wiring = s.mediaWiring
|
||||
if !s.mediaWiredAt.IsZero() {
|
||||
n.WiredAt = s.mediaWiredAt.Format(time.RFC3339)
|
||||
}
|
||||
s.mediaMu.Unlock()
|
||||
return n
|
||||
}
|
||||
|
||||
// restartMediaApps restarts every media app for real (RestartStack: force-recreate with the stack's env — R-1026),
|
||||
// recreates the file browser, records the restart on every drive, then wires the present drives in.
|
||||
func (s *Server) restartMediaApps(reason string) []string {
|
||||
var failed []string
|
||||
for _, name := range s.mediaApps() {
|
||||
if s.appHeld(name) {
|
||||
continue
|
||||
}
|
||||
if err := s.stackMgr.RestartStack(name); err != nil {
|
||||
s.logger.Printf("[WARN] [media] restart %s (%s): %v", name, reason, err)
|
||||
failed = append(failed, name)
|
||||
}
|
||||
}
|
||||
s.SyncFileBrowserMountsForce()
|
||||
if len(failed) == 0 {
|
||||
if err := s.settings.MarkMediaRestarted(); err != nil {
|
||||
s.logger.Printf("[WARN] [media] recording the restart: %v", err)
|
||||
}
|
||||
}
|
||||
// R-1044: the torrents a safe removal paused resume now that the download apps see their drive again (K3-6: no
|
||||
// re-check after a restart).
|
||||
if ids := s.settings.TakeResumable(); len(ids) > 0 {
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
if err := s.wirer().Resume(ids); err != nil {
|
||||
s.logger.Printf("[WARN] [media] resuming %d torrent(s) paused by a safe removal: %v", len(ids), err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
s.logger.Printf("[INFO] [media] media apps restarted (%s); failed: %v", reason, failed)
|
||||
return failed
|
||||
}
|
||||
|
||||
func nextMediaLater(now time.Time) time.Time {
|
||||
loc, err := time.LoadLocation("Europe/Budapest")
|
||||
if err != nil {
|
||||
loc = time.Local
|
||||
}
|
||||
n := now.In(loc)
|
||||
t := time.Date(n.Year(), n.Month(), n.Day(), mediaLaterHour, mediaLaterMinute, 0, 0, loc)
|
||||
if !t.After(n) {
|
||||
t = t.Add(24 * time.Hour)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// handleMediaRestart: POST /api/storage/media/restart {"when":"now"|"later","force":bool}.
|
||||
func (s *Server) handleMediaRestart(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
When string `json:"when"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
|
||||
return
|
||||
}
|
||||
if req.When == "later" {
|
||||
at := nextMediaLater(time.Now())
|
||||
s.mediaMu.Lock()
|
||||
if s.mediaLaterTmr != nil {
|
||||
s.mediaLaterTmr.Stop()
|
||||
}
|
||||
s.mediaLaterAt = at
|
||||
s.mediaLaterTmr = time.AfterFunc(time.Until(at), func() {
|
||||
s.restartMediaApps("the household's „later\"")
|
||||
s.mediaMu.Lock()
|
||||
s.mediaLaterAt = time.Time{}
|
||||
s.mediaMu.Unlock()
|
||||
})
|
||||
s.mediaMu.Unlock()
|
||||
s.logger.Printf("[INFO] [media] media restart scheduled for %s (the household's „later\"; a controller restart forgets it, the notice stays)", at.Format(time.RFC3339))
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]string{"later_at": at.Format("2006-01-02 15:04")})
|
||||
return
|
||||
}
|
||||
if !req.Force {
|
||||
if n, notes := s.wirer().PlayingStreams(); n > 0 {
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "media.streams_playing"), map[string]interface{}{"streams": n, "where": notes})
|
||||
return
|
||||
}
|
||||
}
|
||||
failed := s.restartMediaApps("the household's „restart now\"")
|
||||
writeDiskJSON(w, http.StatusOK, len(failed) == 0, "", map[string]interface{}{"failed": failed})
|
||||
}
|
||||
|
||||
// ServeMediaAPI answers /api/storage/media/* (dispatched by ServeStorageAPI).
|
||||
func (s *Server) ServeMediaAPI(w http.ResponseWriter, r *http.Request) bool {
|
||||
switch {
|
||||
case r.URL.Path == "/api/storage/media/status" && r.Method == http.MethodGet:
|
||||
writeDiskJSON(w, http.StatusOK, true, "", s.MediaNotice(true))
|
||||
case r.URL.Path == "/api/storage/media/restart" && r.Method == http.MethodPost:
|
||||
s.handleMediaRestart(w, r)
|
||||
case r.URL.Path == "/api/storage/media/wire" && r.Method == http.MethodPost:
|
||||
writeDiskJSON(w, http.StatusOK, true, "", s.RunMediaWiring("the household's „connect now\""))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Safe removal and return (R-1044) ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// mediaSafeRemoval runs the media half of a safe removal BEFORE the agent unmounts: pause the drive's torrents, mark the
|
||||
// drive removed (it stays registered), recreate the media apps and the file browser without it. Returns the paused
|
||||
// torrents for the record.
|
||||
func (s *Server) mediaSafeRemoval(path, durableID string) []string {
|
||||
d := mediawire.Drive{Path: path}
|
||||
paused, err := s.wirer().PauseOnDrive(d)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] [media] safe removal %s: pausing its torrents: %v (continuing — a paused torrent is a courtesy, the release below is the safety)", path, err)
|
||||
}
|
||||
if err := s.settings.SetEjected(path, durableID, paused); err != nil {
|
||||
s.logger.Printf("[WARN] [media] safe removal %s: recording it: %v", path, err)
|
||||
}
|
||||
for _, name := range s.mediaApps() {
|
||||
if s.appHeld(name) {
|
||||
continue
|
||||
}
|
||||
// The override no longer lists the removed drive, so the recreate lets go of it.
|
||||
if err := s.stackMgr.RestartStack(name); err != nil {
|
||||
s.logger.Printf("[WARN] [media] safe removal %s: recreating %s without it: %v", path, name, err)
|
||||
}
|
||||
}
|
||||
s.SyncFileBrowserMountsForce()
|
||||
s.logger.Printf("[INFO] [media] safe removal %s: %d torrent(s) paused, the media apps and the file browser let go of it", path, len(paused))
|
||||
return paused
|
||||
}
|
||||
|
||||
// reconcileEjectedDrives handles a safely removed drive: once its disk has LEFT (not attached any more) and is BACK
|
||||
// (attached again), re-bind it under the parent, clear the removal, resume its torrents, and raise the notice.
|
||||
func (s *Server) reconcileEjectedDrives(ctx context.Context, agent *agentapi.Client, disks []agentapi.DiskInfo) {
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.EjectedAt == "" {
|
||||
continue
|
||||
}
|
||||
var disk *agentapi.DiskInfo
|
||||
for i := range disks {
|
||||
if (sp.EjectDurableID != "" && disks[i].DurableID == sp.EjectDurableID) || (sp.EjectDurableID == "" && disks[i].GuestPath == sp.Path) {
|
||||
disk = &disks[i]
|
||||
}
|
||||
}
|
||||
attached := disk != nil && disk.State == "attached"
|
||||
if !sp.EjectSeenGone {
|
||||
if !attached {
|
||||
_ = s.settings.SetEjectSeenGone(sp.Path)
|
||||
s.logger.Printf("[INFO] [media] safely removed drive %s: its disk has left the box", sp.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !attached {
|
||||
continue
|
||||
}
|
||||
if !disk.BoundUnderParent && disk.MountPath != "" {
|
||||
if err := agent.GuestAttach(ctx, disk.MountPath); err != nil {
|
||||
s.logger.Printf("[WARN] [media] safely removed drive %s is back: re-attach (raw %s): %v", sp.Path, disk.MountPath, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := s.settings.ClearEjected(sp.Path); err != nil {
|
||||
s.logger.Printf("[WARN] [media] safely removed drive %s is back: clearing the removal: %v", sp.Path, err)
|
||||
continue
|
||||
}
|
||||
s.logger.Printf("[INFO] [media] safely removed drive %s is BACK — registered as before, no household step", sp.Path)
|
||||
s.mediaDriveEvent(sp.Path, "returned")
|
||||
// The file browser holds no stream: it gets the drive back at once. The torrents resume after the media apps'
|
||||
// restart (restartMediaApps → TakeResumable).
|
||||
go s.SyncFileBrowserMountsForce()
|
||||
}
|
||||
}
|
||||
|
||||
// ejectDurableID finds the durable id of the disk behind a registered path (for finding it again after the return).
|
||||
func ejectDurableID(disks []agentapi.DiskInfo, path string) string {
|
||||
for _, d := range disks {
|
||||
if d.GuestPath == path {
|
||||
return d.DurableID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var _ = settings.StoragePath{}
|
||||
|
||||
// plexClaimCode is the shape of a plex.tv claim code (claim-<20-ish>); anything else is refused before a call.
|
||||
var plexClaimCode = regexp.MustCompile(`^claim-[A-Za-z0-9_-]{8,64}$`)
|
||||
|
||||
// appPlexClaimHandler (R-1028, `15` §10 K6): the household's code claims the RUNNING Plex — POST
|
||||
// http://plex:32400/myplex/claim?token=<code> (no Plex token needed: the server is unclaimed). The code is never logged
|
||||
// and never stored. The setup gate opens only when Plex itself then says claimed (the probe), never on the press alone.
|
||||
func (s *Server) appPlexClaimHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !plexClaimCode.MatchString(strings.TrimSpace(req.Code)) {
|
||||
escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "err.plex_claim.bad_code"))
|
||||
return
|
||||
}
|
||||
code, err := plexClaim(strings.TrimSpace(req.Code))
|
||||
s.logger.Printf("[INFO] [web] plex: the household's claim code given to the running server -> HTTP %d (err %v; the code is not logged)", code, err)
|
||||
if err != nil || code != http.StatusOK {
|
||||
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.plex_claim.refused"))
|
||||
return
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
if has, done, _, perr := s.stackMgr.SetupGateProbe("plex"); has && perr == nil && done {
|
||||
if oerr := s.stackMgr.OpenSetupGate("plex", stacks.SetupGateByProbe); oerr != nil && !errors.Is(oerr, stacks.ErrSetupGateNotClosed) {
|
||||
s.logger.Printf("[WARN] [web] plex: claimed, but opening the setup gate failed: %v", oerr)
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] plex: claimed — the setup gate is open (R-1028)")
|
||||
escrowJSON(w, http.StatusOK, map[string]any{"claimed": true}, "")
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.plex_claim.not_claimed"))
|
||||
}
|
||||
|
||||
// plexClaim is the call (a seam for tests).
|
||||
var plexClaim = func(code string) (int, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://plex:32400/myplex/claim?token="+url.QueryEscape(code), nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// „Later" is tonight 03:30 Budapest time — or tomorrow's when it is already past 03:30.
|
||||
func TestNextMediaLater(t *testing.T) {
|
||||
loc, _ := time.LoadLocation("Europe/Budapest")
|
||||
for now, want := range map[time.Time]string{
|
||||
time.Date(2026, 10, 15, 21, 0, 0, 0, loc): "2026-10-16 03:30",
|
||||
time.Date(2026, 10, 15, 2, 0, 0, 0, loc): "2026-10-15 03:30",
|
||||
time.Date(2026, 10, 15, 3, 30, 0, 0, loc): "2026-10-16 03:30",
|
||||
} {
|
||||
if got := nextMediaLater(now).Format("2006-01-02 15:04"); got != want {
|
||||
t.Errorf("now %s: later = %s, want %s", now.Format(time.RFC3339), got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-1028: the Plex claim box renders only for a closed Plex gate — render test per branch (the seam-wiring rule).
|
||||
// COMPANION RED-PROOF: remove the {{if .PlexClaim}} block → the first case fails. Restored.
|
||||
func TestPlexClaimBox_RendersPerBranch(t *testing.T) {
|
||||
st := lcStack("plex", "", true)
|
||||
st.State = stacks.StateRunning
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
plexClaim bool
|
||||
gateClosed bool
|
||||
want bool
|
||||
}{{"closed plex", true, true, true}, {"gate open", false, false, false}, {"other app closed", false, true, false}} {
|
||||
d := appInfoData(st)
|
||||
d["SetupGateClosed"] = c.gateClosed
|
||||
d["PlexClaim"] = c.plexClaim
|
||||
h := renderBackupPage(t, "app_info", d)
|
||||
if got := strings.Contains(h, `data-plex-claim="true"`) && strings.Contains(h, `id="plex-claim-code"`); got != c.want {
|
||||
t.Errorf("%s: claim box shown=%v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The code is checked for its shape before any call: no call for something that is not a claim code.
|
||||
func TestPlexClaimCodeShape(t *testing.T) {
|
||||
for code, ok := range map[string]bool{"claim-AbCdEf123456789_-xyz": true, "claim-short": false, "claim-abcdefgh": true, "claim-": false,
|
||||
"https://plex.tv/claim": false, "claim-abc def": false, "": false} {
|
||||
if got := plexClaimCode.MatchString(code); got != ok {
|
||||
t.Errorf("%q: accepted=%v, want %v", code, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/mediawire"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
|
||||
@@ -91,9 +92,20 @@ type Server struct {
|
||||
fileBrowserMu sync.Mutex
|
||||
// fbReqMu guards the file-browser sync's single-flight counters (R-695, v0.275.0): fbReqGen counts
|
||||
// requests, fbDoneGen is the newest request a finished sync is known to have covered.
|
||||
fbReqMu sync.Mutex
|
||||
fbReqGen uint64
|
||||
fbDoneGen uint64
|
||||
fbReqMu sync.Mutex
|
||||
// fbForce (R-1038): the next FileBrowser sync recreates even when nothing changed (a drive came back).
|
||||
fbForceMu sync.Mutex
|
||||
fbForce bool
|
||||
// media drive events (v0.313.0): the wiring (R-1024) and the „restart later" timer.
|
||||
mediaMu sync.Mutex
|
||||
mediaWiring []mediawire.Result
|
||||
mediaWiredAt time.Time
|
||||
mediaLaterAt time.Time
|
||||
mediaLaterTmr *time.Timer
|
||||
mediaWirer *mediawire.Wirer
|
||||
mediaSoonTmr *time.Timer
|
||||
fbReqGen uint64
|
||||
fbDoneGen uint64
|
||||
// syncFileBrowserHook replaces the sync's body in tests (the single-flight is the unit under test).
|
||||
syncFileBrowserHook func()
|
||||
// langSwitchSync replaces the language switch's file-browser sync in tests (R-691).
|
||||
@@ -850,6 +862,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// path wins. POST (not GET) so CsrfProtect covers it and it is not cacheable — see the handler.
|
||||
// v0.280.0: the household's two presses on the app page — "Done, I set it up" (decision 46) and "I changed
|
||||
// it" under a known default login (R-710). POST, so CsrfProtect covers them.
|
||||
case path == "/apps/plex/plex-claim" && r.Method == http.MethodPost:
|
||||
s.appPlexClaimHandler(w, r)
|
||||
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/setup-gate/open") && r.Method == http.MethodPost:
|
||||
s.appSetupGateOpenHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/setup-gate/open"))
|
||||
// R-1007: the household dismisses the lost-files note. POST, so CsrfProtect covers it.
|
||||
|
||||
@@ -315,6 +315,7 @@ func (s *Server) registerStoragePath(where, label string, setDefault bool) error
|
||||
return util.MsgError("err.web.regisztracio_sikertelen", err)
|
||||
}
|
||||
go s.SyncFileBrowserMounts()
|
||||
s.mediaDriveEvent(where, "added") // R-1023: the media apps see it after a restart
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -370,6 +371,9 @@ func (s *Server) storageWizardPageHandler(w http.ResponseWriter, r *http.Request
|
||||
|
||||
// ServeStorageAPI dispatches /api/storage/* (guided init/attach/eject orchestration).
|
||||
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/storage/media/") && s.ServeMediaAPI(w, r) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == "/api/storage/init" && r.Method == http.MethodPost:
|
||||
s.handleStorageInit(w, r)
|
||||
@@ -983,12 +987,35 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
// R-1044 (v0.313.0): with media apps on the box, a safe removal FIRST lets go of the drive — pause only its torrents,
|
||||
// recreate the media apps and the file browser without it — and KEEPS it registered (removed), so its return needs no
|
||||
// household step. Measured 2026-10-14 (K3-3): the old eject left running apps holding the filesystem and forgot the
|
||||
// drive („Meglévő meghajtó csatolása" after every return).
|
||||
mediaRemoval := len(s.mediaApps()) > 0 && strings.HasPrefix(req.Where, StableParentDir+"/")
|
||||
if mediaRemoval {
|
||||
durable := ""
|
||||
if dr, derr := agent.Disks(r.Context()); derr == nil {
|
||||
durable = ejectDurableID(dr.Disks, req.Where)
|
||||
}
|
||||
s.mediaSafeRemoval(req.Where, durable)
|
||||
s.mediaDriveEvent(req.Where, "removed")
|
||||
}
|
||||
// The registered path is the STABLE /mnt/felhom-drives/<name>; the agent operates on the raw mount.
|
||||
res, err := agent.EjectDisk(r.Context(), agentWhere(req.Where))
|
||||
if err != nil {
|
||||
if mediaRemoval {
|
||||
// The unmount did not happen: the drive is still here — undo the removal mark so the apps get it back.
|
||||
_ = s.settings.ClearEjected(req.Where)
|
||||
s.mediaDriveEvent(req.Where, "returned")
|
||||
}
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
if mediaRemoval {
|
||||
s.logger.Printf("[INFO] [web] eject: %s safely removed and KEPT registered (media apps on the box, R-1044)", req.Where)
|
||||
writeDiskJSON(w, http.StatusOK, true, "", res)
|
||||
return
|
||||
}
|
||||
// Deregister the path (best-effort — the unmount already succeeded).
|
||||
if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
|
||||
s.logger.Printf("[WARN] [web] eject: unmounted %s but deregister failed: %v", req.Where, rerr)
|
||||
|
||||
@@ -112,6 +112,16 @@
|
||||
<h3>{{T "app_info.setup_gate_title"}}</h3>
|
||||
<p>{{T "app_info.setup_gate_closed"}}</p>{{- if .SignupNative}}
|
||||
<p class="form-hint" data-restarts-once="true">{{T "app_info.restarts_once"}}</p>{{- end}}
|
||||
{{- if .PlexClaim}}
|
||||
<div class="plex-claim" data-plex-claim="true">
|
||||
<p>{{T "app_info.plex_claim_intro"}}</p>
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="plex-claim-code" class="form-control" placeholder="claim-xxxxxxxxxxxxxxxxxxxx" autocomplete="off" spellcheck="false">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="plex-claim-btn" onclick="plexClaim(this)">{{T "app_info.plex_claim_btn"}}</button>
|
||||
</div>
|
||||
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
|
||||
</div>
|
||||
{{- end}}
|
||||
{{- if .SetupGateHasProbe}}
|
||||
<p class="form-hint">{{T "app_info.setup_gate_probe"}}</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/setup-gate/open')">{{T "app_info.setup_gate_done_btn"}}</button>
|
||||
@@ -368,6 +378,16 @@ function icCopyPw(btn) {
|
||||
{{- if or .SetupGateClosed .SignupClosed .CloseSignupOffered (and .Stack.Deployed .KnownLoginLine) .LostFiles}}
|
||||
<script>
|
||||
// v0.280.0: the household's presses on this page — "Done, I set it up" (decision 46), "I changed it" (R-710).
|
||||
// R-1028: the household's Plex code, given to the RUNNING server; the gate opens when Plex says it is claimed.
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<h2>{{T "dashboard.vezerlopult"}}</h2>
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
</div>
|
||||
{{template "media_notice" .}}
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{{/*
|
||||
media_notice (v0.313.0, `09` §3 decisions 238/239/245) — the media drive notice: a drive was added, removed or came back,
|
||||
so the media apps must restart to see it; „restart now" (after a stream check) or „later" (tonight 03:30); per drive
|
||||
„apps restarted since this change: yes/no"; and the result of the drive wiring. Rendered empty and filled from
|
||||
GET /api/storage/media/status, so every page that includes it shows the same truth. Hidden when there is nothing to say.
|
||||
*/}}
|
||||
{{define "media_notice"}}
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>{{T "media.notice_title"}}</h3>
|
||||
<p class="settings-card-desc">{{T "media.notice_body"}}</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">{{T "media.restart_now"}}</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">{{T "media.restart_later"}}</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>{{T "media.wiring_title"}}</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>{{T "media.jellyfin_key_hint"}}</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">{{T "media.wire_now"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"{{T "media.change_added"}}",removed:"{{T "media.change_removed"}}",returned:"{{T "media.change_returned"}}",
|
||||
yes:"{{T "media.restarted_yes"}}",no:"{{T "media.restarted_no"}}",apps:"{{T "media.apps"}}",stale:"{{T "media.stale"}}",
|
||||
later:"{{T "media.later_at"}}",streams:"{{T "media.streams_playing"}}",done:"{{T "media.restart_done"}}",
|
||||
err:"{{T "media.error"}}",household:"{{T "media.state_household"}}"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -4,6 +4,7 @@
|
||||
<div class="page-header">
|
||||
<h2>{{T "storage.tarhely_meghajtok"}}</h2>
|
||||
</div>
|
||||
{{template "media_notice" .}}
|
||||
|
||||
<!-- Section: Storage Paths -->
|
||||
<div class="settings-card">
|
||||
|
||||
@@ -3756,3 +3756,8 @@ html::-webkit-scrollbar-track { background: var(--bg-0); }
|
||||
.kept-delete-form { display: flex; flex-direction: column; gap: .4rem; margin-top: .5rem; max-width: 32rem; }
|
||||
.kept-delete-text { color: var(--warn); margin: 0; }
|
||||
.storage-kept-link { margin-top: .75rem; }
|
||||
|
||||
/* media drive notice (v0.313.0) */
|
||||
.media-notice-list { margin: .4rem 0 .6rem 1.1rem; padding: 0; }
|
||||
.media-notice-list li { margin: .15rem 0; }
|
||||
.media-notice-actions { display: flex; gap: .5rem; flex-wrap: wrap; margin: .6rem 0; }
|
||||
|
||||
@@ -595,6 +595,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
+10
@@ -596,6 +596,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
+10
@@ -611,6 +611,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -599,6 +599,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu" class="no-js">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Plex — Felhom.eu</title>
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
|
||||
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
|
||||
<meta name="csrf-token" content="tok">
|
||||
<script>
|
||||
|
||||
|
||||
document.documentElement.className=document.documentElement.className.replace('no-js','js');
|
||||
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
|
||||
var fhEN=document.documentElement.lang==='en';
|
||||
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
|
||||
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
|
||||
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
|
||||
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function felhomConfirm(el,question,onYes){
|
||||
if(!el||el.dataset.fcOpen)return;
|
||||
el.dataset.fcOpen='1';
|
||||
var wrap=document.createElement('span');wrap.className='inline-confirm';
|
||||
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
|
||||
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
|
||||
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
|
||||
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
|
||||
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
|
||||
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
|
||||
no.addEventListener('click',close);
|
||||
yes.addEventListener('click',function(){close();onYes();});
|
||||
}
|
||||
document.addEventListener('click',function(e){
|
||||
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
|
||||
if(!btn)return;
|
||||
e.preventDefault();
|
||||
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
|
||||
var form=btn.closest('form');
|
||||
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
document.addEventListener('click',function(e){
|
||||
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
|
||||
if(!btn)return;
|
||||
var group=btn.closest('.nav-group');
|
||||
if(!group)return;
|
||||
var willOpen=!group.classList.contains('is-open');
|
||||
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
|
||||
g.classList.remove('is-open');
|
||||
var t=g.querySelector('.nav-group-toggle');
|
||||
if(t)t.setAttribute('aria-expanded','false');
|
||||
});
|
||||
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
|
||||
});
|
||||
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
|
||||
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
|
||||
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
|
||||
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
|
||||
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
|
||||
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
|
||||
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
|
||||
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
|
||||
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
|
||||
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
|
||||
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
|
||||
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
|
||||
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
|
||||
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
|
||||
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
|
||||
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
|
||||
</svg>
|
||||
<header class="mobile-topbar">
|
||||
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
|
||||
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
|
||||
<svg class="ico"><use href="#i-menu"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="nav-backdrop" hidden></div>
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
|
||||
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
|
||||
<li><a href="/stacks" class="active"><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>Tárhely<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/storage" class="">Meghajtók</a></li>
|
||||
<li><a href="/storage/network" class="">Hálózati tárhely</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>Biztonsági mentés<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/backups" class="">Áttekintés</a></li>
|
||||
<li><a href="/backups/remote" class="">Távoli mentés</a></li>
|
||||
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
|
||||
<li><a href="/backups/restore" class="">Visszaállítás</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>Megosztás<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/sharing" class="">Hálózati megosztás</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/monitoring" class=""><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
|
||||
<li><a href="/debug" class=""><svg class="ico"><use href="#i-wrench"/></svg>Hibakeresés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="nav-group-label">Beállítások</div>
|
||||
<ul class="nav-links nav-links-sub">
|
||||
<li><a href="/settings" class=""><svg class="ico"><use href="#i-settings"/></svg>Rendszer</a></li>
|
||||
<li><a href="/settings/notifications" class=""><svg class="ico"><use href="#i-bell"/></svg>Értesítések</a></li>
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="page-header">
|
||||
<div style="display:flex;align-items:center;gap:1rem">
|
||||
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
|
||||
<h2>Plex</h2>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:.5rem">
|
||||
|
||||
<span class="stack-state-badge state-run">Fut</span>
|
||||
|
||||
|
||||
|
||||
|
||||
<a href="/stacks/plex/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||
|
||||
<a href="/stacks/plex/export" class="btn btn-sm btn-outline">Exportálás</a>
|
||||
<a href="/stacks/plex/deploy" class="btn btn-sm btn-outline">Beállítások</a>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-info-hero">
|
||||
<img class="app-info-logo" src="/static/assets/plex-logo.svg"
|
||||
alt="Plex" data-fallback="/static/app-placeholder.svg"
|
||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/plex-logo.png';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
|
||||
<div class="app-info-hero-text">
|
||||
|
||||
<p class="app-info-tagline"></p>
|
||||
|
||||
<div class="stack-meta-badges">
|
||||
<span class="meta-badge">~ RAM</span>
|
||||
<span class="meta-badge"></span>
|
||||
|
||||
<span class="meta-badge meta-badge-warn">Csak x86</span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots" id="screenshots">
|
||||
<img src="/static/assets/plex-screenshot-1.webp" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
<img src="/static/assets/plex-screenshot-2.webp" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
<img src="/static/assets/plex-screenshot-3.webp" alt="" class="app-screenshot"
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
<div class="app-info-card" style="margin-top:1rem" id="setup-gate-card">
|
||||
<h3>Első beállítás</h3>
|
||||
<p>Most csak te éred el ezt az alkalmazást, amíg be vagy jelentkezve a vezérlőpultba. Így más nem hozhatja létre az első admin fiókot. Nyisd meg, és végezd el az első beállítást.</p>
|
||||
<div class="plex-claim" data-plex-claim="true">
|
||||
<p>Nyisd meg a https://plex.tv/claim oldalt a Plex-fiókoddal, másold ki a kódot, és illeszd be ide. A kód 4 percig érvényes, de a Plex már fut, így nem kell sietned a telepítéssel.</p>
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="plex-claim-code" class="form-control" placeholder="claim-xxxxxxxxxxxxxxxxxxxx" autocomplete="off" spellcheck="false">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="plex-claim-btn" onclick="plexClaim(this)">Plex összekapcsolása</button>
|
||||
</div>
|
||||
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
|
||||
</div>
|
||||
<p class="form-hint">Ha kész, a doboz magától észreveszi, és mindenkinek megnyitja az alkalmazást.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/plex/setup-gate/open')">Kész, beállítottam</button>
|
||||
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</main>
|
||||
<script>
|
||||
|
||||
|
||||
|
||||
(function(){
|
||||
var burger=document.querySelector('.nav-burger');
|
||||
var sidebar=document.getElementById('sidebar');
|
||||
var backdrop=document.querySelector('.nav-backdrop');
|
||||
if(!burger||!sidebar||!backdrop)return;
|
||||
function setOpen(open){
|
||||
sidebar.classList.toggle('is-open',open);
|
||||
document.body.classList.toggle('nav-open',open);
|
||||
backdrop.hidden=!open;
|
||||
burger.setAttribute('aria-expanded',open?'true':'false');
|
||||
}
|
||||
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
|
||||
backdrop.addEventListener('click',function(){setOpen(false);});
|
||||
document.addEventListener('keydown',function(e){
|
||||
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
|
||||
});
|
||||
})();
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
|
||||
var card = e.target.closest('[data-href]');
|
||||
if (card) window.location.href = card.dataset.href;
|
||||
});
|
||||
async function checkBeforeDeploy(e, name) {
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name);
|
||||
var data = await resp.json();
|
||||
if (data.ok && data.data && data.data.deployed) {
|
||||
e.preventDefault();
|
||||
showAlert('Ez az alkalmazás már telepítve van.');
|
||||
window.location.reload();
|
||||
return false;
|
||||
}
|
||||
} catch(err) {}
|
||||
return true;
|
||||
}
|
||||
async function syncTemplates() {
|
||||
const btn = document.getElementById('sync-btn');
|
||||
const toast = document.getElementById('sync-toast');
|
||||
if (!btn) return;
|
||||
const origText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '↻ Frissítés...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (toast) {
|
||||
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
if (toast) {
|
||||
toast.textContent = 'Hálózati hiba: ' + err.message;
|
||||
toast.className = 'sync-toast sync-toast-err';
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
}
|
||||
btn.innerHTML = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
async function stackAction(event, name, action) {
|
||||
const btn = event.currentTarget;
|
||||
const origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Folyamatban...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (action === 'update') {
|
||||
followUpdate(name, btn);
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
function followUpdate(name, btn) {
|
||||
var started = Date.now();
|
||||
var timer = setInterval(async function() {
|
||||
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
|
||||
try {
|
||||
var r = await fetch('/api/stacks/' + name);
|
||||
var d = await r.json();
|
||||
if (!d.ok || !d.data) return;
|
||||
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
|
||||
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
|
||||
} catch (e) {}
|
||||
}, 3000);
|
||||
}
|
||||
async function deleteOrphanStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'delete-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
|
||||
var data = await resp.json();
|
||||
var hddInfo = '';
|
||||
var checkboxHTML = '';
|
||||
if (data.ok && data.data && data.data.has_hdd_data) {
|
||||
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
|
||||
data.data.hdd_paths.forEach(function(p) {
|
||||
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
hddInfo += '</div>';
|
||||
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
|
||||
}
|
||||
|
||||
if (data.ok && data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
var udPaths = '';
|
||||
data.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
||||
hddInfo += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
hddInfo + checkboxHTML +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
|
||||
'</div>';
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeDeleteModal() {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmDelete(name) {
|
||||
var btn = document.getElementById('confirm-delete-btn');
|
||||
var checkbox = document.getElementById('delete-hdd-check');
|
||||
var removeHDD = checkbox ? checkbox.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Törlés folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name, {
|
||||
method: 'DELETE',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.hdd_note) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
||||
}
|
||||
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen törölve!</h3>' +
|
||||
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') törölve lett.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
}
|
||||
async function removeStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'remove-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var [hddResp, backupResp] = await Promise.all([
|
||||
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
|
||||
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
|
||||
'<li>Telepítési konfiguráció (app.yaml)</li>' +
|
||||
'<li>Másodlagos mentés ütemezése</li>' +
|
||||
'</ul></div>';
|
||||
|
||||
var hddCheckbox = '';
|
||||
if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
|
||||
var hddPaths = '';
|
||||
hddResp.data.hdd_paths.forEach(function(p) {
|
||||
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
|
||||
'Ha újratelepíted az alkalmazást, az adatokat újra importálnod kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
|
||||
'</div></div>';
|
||||
hddCheckbox = '\x3Cscript>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
|
||||
}
|
||||
|
||||
var backupCheckbox = '';
|
||||
if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) {
|
||||
var bkPaths = '';
|
||||
backupResp.data.backup_paths.forEach(function(p) {
|
||||
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
});
|
||||
if (bkPaths) {
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mentési adatok:</strong>' + bkPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
|
||||
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
|
||||
'</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (hddResp.ok && hddResp.data && hddResp.data.userdata_kept && hddResp.data.userdata_kept.length > 0) {
|
||||
var udPaths = '';
|
||||
hddResp.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
||||
sections += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
sections +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
|
||||
'</div>' + hddCheckbox;
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeRemoveModal() {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmRemoveStack(name) {
|
||||
var btn = document.getElementById('confirm-remove-btn');
|
||||
var hddCheck = document.getElementById('remove-hdd-check');
|
||||
var backupCheck = document.getElementById('remove-backup-check');
|
||||
var removeHDD = hddCheck ? hddCheck.checked : false;
|
||||
var removeBackups = backupCheck ? backupCheck.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Eltávolítás folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/remove', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.hdd_note) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
||||
}
|
||||
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen eltávolítva!</h3>' +
|
||||
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
btn.disabled = true;
|
||||
fetch(url, {method: 'POST', headers: csrfHeaders(), credentials: 'same-origin'})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
if (!j.ok) { throw new Error(j.error || ''); }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (e) { btn.disabled = false; if (err) { err.textContent = e.message; err.style.display = 'inline'; } });
|
||||
}
|
||||
</script>
|
||||
@@ -596,6 +596,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -597,6 +597,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -596,6 +596,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -596,6 +596,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -598,6 +598,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
+10
@@ -597,6 +597,16 @@
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function plexClaim(btn) {
|
||||
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
|
||||
var code = (document.getElementById('plex-claim-code') || {}).value || '';
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
|
||||
@@ -177,6 +177,74 @@
|
||||
<span class="domain-badge">example.hu</span>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
<div class="stat-value">1</div>
|
||||
|
||||
@@ -177,6 +177,74 @@
|
||||
<span class="domain-badge">example.hu</span>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
<div class="stat-value">3</div>
|
||||
|
||||
@@ -177,6 +177,74 @@
|
||||
<span class="domain-badge">example.hu</span>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
<div class="stat-value">1</div>
|
||||
|
||||
@@ -177,6 +177,74 @@
|
||||
<span class="domain-badge">example.hu</span>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
<div class="stat-value">0</div>
|
||||
|
||||
@@ -177,6 +177,74 @@
|
||||
<span class="domain-badge">example.hu</span>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
<div class="stat-value">0</div>
|
||||
|
||||
@@ -176,6 +176,74 @@
|
||||
<h2>Tárhely — Meghajtók</h2>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Adattárolók</h3>
|
||||
|
||||
@@ -176,6 +176,74 @@
|
||||
<h2>Tárhely — Meghajtók</h2>
|
||||
</div>
|
||||
|
||||
<div id="media-notice" class="settings-card media-notice" hidden>
|
||||
<h3>A médiaalkalmazásoknak újra kell indulniuk</h3>
|
||||
<p class="settings-card-desc">Egy meghajtó megváltozott. A médiaalkalmazások csak újraindítás után látják a változást.</p>
|
||||
<ul id="media-notice-drives" class="media-notice-list"></ul>
|
||||
<p id="media-notice-apps" class="form-hint"></p>
|
||||
<p id="media-notice-stale" class="form-hint" hidden></p>
|
||||
<p id="media-notice-later" class="form-hint" hidden></p>
|
||||
<div class="media-notice-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="media-restart-now">Újraindítás most</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-restart-later">Később (ma éjjel 3:30)</button>
|
||||
</div>
|
||||
<div id="media-notice-out"></div>
|
||||
<div id="media-wiring" hidden>
|
||||
<h4>Meghajtók bekötése az alkalmazásokba</h4>
|
||||
<ul id="media-wiring-list" class="media-notice-list"></ul>
|
||||
<p id="media-wiring-hint" class="form-hint" hidden>Jellyfin: Vezérlőpult → API-kulcsok → +, a kulcs neve: felhom. Utána nyomd meg a „Bekötés most” gombot.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline" id="media-wire-now">Bekötés most</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.getElementById('media-notice'); if(!box) return;
|
||||
var csrf=(document.querySelector('meta[name="csrf-token"]')||{}).content||'';
|
||||
var L={added:"hozzáadva",removed:"eltávolítva",returned:"visszatért",
|
||||
yes:"az alkalmazások azóta újraindultak: igen",no:"az alkalmazások azóta újraindultak: nem",apps:"Érintett alkalmazások:",stale:"Még egy halott meghajtót tart:",
|
||||
later:"Ütemezve:",streams:"Most is megy egy lejátszás. Az újraindítás megszakítja. Biztosan újraindítod?",done:"Újraindítva. A meghajtók bekötése egy-két perc múlva kész.",
|
||||
err:"Hiba:",household:"egy lépés kell tőled"};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function li(t){var e=document.createElement('li'); e.textContent=t; return e;}
|
||||
async function load(){
|
||||
try{
|
||||
var r=await fetch('/api/storage/media/status',{credentials:'same-origin'}); var j=await r.json(); if(!j.ok||!j.data) return;
|
||||
var d=j.data; if(!d.apps||!d.apps.length) return;
|
||||
var ul=document.getElementById('media-notice-drives'); ul.innerHTML='';
|
||||
(d.drives||[]).forEach(function(x){ ul.appendChild(li(x.label+' — '+(L[x.change]||x.change)+' — '+(x.restarted?L.yes:L.no))); });
|
||||
document.getElementById('media-notice-apps').textContent=L.apps+' '+d.apps.join(', ');
|
||||
var st=document.getElementById('media-notice-stale'); if(d.stale&&d.stale.length){st.textContent=L.stale+' '+d.stale.join(', '); st.hidden=false;}
|
||||
var la=document.getElementById('media-notice-later'); if(d.later_at){la.textContent=L.later+' '+d.later_at; la.hidden=false;}
|
||||
if(d.wiring&&d.wiring.length){
|
||||
var wl=document.getElementById('media-wiring-list'); wl.innerHTML=''; var hh=false;
|
||||
d.wiring.forEach(function(x){ if(x.app==='folders') return; var t=x.app+': '+(x.state==='household'?L.household:x.state)+(x.detail&&x.state==='error'?' ('+x.detail+')':''); wl.appendChild(li(t)); if(x.state==='household'&&x.app==='jellyfin') hh=true; });
|
||||
document.getElementById('media-wiring-hint').hidden=!hh; document.getElementById('media-wiring').hidden=false;
|
||||
}
|
||||
var show=d.pending||!!(d.wiring&&d.wiring.some(function(x){return x.state==='household'||x.state==='error';}));
|
||||
document.querySelector('.media-notice-actions').hidden=!d.pending;
|
||||
box.hidden=!show;
|
||||
}catch(e){}
|
||||
}
|
||||
async function post(url,body){
|
||||
var r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(body||{})});
|
||||
var j={}; try{j=await r.json();}catch(e){} return {status:r.status,j:j};
|
||||
}
|
||||
var out=document.getElementById('media-notice-out');
|
||||
async function restart(force){
|
||||
out.innerHTML='';
|
||||
var res=await post('/api/storage/media/restart',{when:'now',force:!!force});
|
||||
if(res.status===409){ felhomConfirm(document.getElementById('media-restart-now'), L.streams, function(){ restart(true); }); return; }
|
||||
out.innerHTML=res.j.ok?'<div class="alert alert-info">'+esc(L.done)+'</div>':'<div class="alert alert-error">'+esc(L.err)+' '+esc(res.j.error||((res.j.data||{}).failed||[]).join(', '))+'</div>';
|
||||
load();
|
||||
}
|
||||
document.getElementById('media-restart-now').addEventListener('click',function(){restart(false);});
|
||||
document.getElementById('media-restart-later').addEventListener('click',async function(){ await post('/api/storage/media/restart',{when:'later'}); load(); });
|
||||
document.getElementById('media-wire-now').addEventListener('click',async function(){ await post('/api/storage/media/wire',{}); load(); });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Adattárolók</h3>
|
||||
|
||||
Reference in New Issue
Block a user