add: processing controls — start/hold, pause/resume, retry
Three lifecycle controls on the dashboard, sharing one /api surface and the status feed: - Start/hold gate (#11): the watcher tracks the queue but holds processing until POST /api/start. Held by default; AV1DAE_AUTOSTART=1 restores start- on-boot. NOTE: flips the previous auto-start default. - Pause/resume the active encode (#10): SIGSTOP/SIGCONT on the ffmpeg process (libsvtav1 is in-process, so one signal suspends all its threads). Resumes exactly where it left off; the tracker excludes paused time from elapsed. - Retry a failed file (#3): POST /api/retry?file=NAME moves it from failed/ back to input/, with a base-name guard against path traversal. /status now reports running + the failed list; snapshots carry a paused flag. Verified live: held queue, retry move, traversal -> 400, and a real encode suspending to process state T on pause and S on resume. Closes #3 Closes #10 Closes #11
This commit is contained in:
@@ -114,6 +114,22 @@
|
||||
.day:first-child { margin-top:0; }
|
||||
|
||||
@media (prefers-reduced-motion:reduce){ .dot.ok{animation:none;} .bar.indet>i{animation:none; width:100%; opacity:.4;} }
|
||||
|
||||
/* control bar */
|
||||
.controls { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-bottom:1.25rem; min-height:1px; }
|
||||
.ctl { font-family:var(--mono); font-size:.82rem; cursor:pointer; border-radius:7px; padding:.45rem .9rem;
|
||||
background:var(--panel-2); color:var(--text); border:1px solid var(--line); }
|
||||
.ctl:hover { border-color:var(--amber); color:var(--amber); }
|
||||
.ctl.primary { background:var(--amber); color:#1a1200; border-color:var(--amber); font-weight:600; }
|
||||
.ctl.primary:hover { background:var(--amber-soft); color:#1a1200; }
|
||||
.ctl.small { font-size:.72rem; padding:.25rem .65rem; }
|
||||
.ctl-state { font-family:var(--mono); font-size:.72rem; color:var(--muted); margin-left:.3rem; text-transform:uppercase; letter-spacing:.08em; }
|
||||
.ctl-state.paused { color:var(--amber); }
|
||||
.count { font-family:var(--mono); color:var(--red); }
|
||||
/* failed list */
|
||||
#failed .frow { display:flex; align-items:center; gap:.6rem; padding:.35rem 0; border-bottom:1px solid var(--line); font-family:var(--mono); font-size:.82rem; }
|
||||
#failed .frow:last-child { border-bottom:0; }
|
||||
#failed .frow .fn { flex:1; color:var(--text); word-break:break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -124,10 +140,17 @@
|
||||
<span class="live"><span class="dot" id="dot"></span><span id="livetext">connecting</span></span>
|
||||
</header>
|
||||
|
||||
<div class="controls" id="controls"></div>
|
||||
|
||||
<section class="card" id="job">
|
||||
<div id="job-body"></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="failedCard" hidden>
|
||||
<p class="eyebrow">Failed <span class="count" id="fcount"></span></p>
|
||||
<div id="failed"></div>
|
||||
</section>
|
||||
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<p class="eyebrow">Queue</p>
|
||||
@@ -356,17 +379,60 @@
|
||||
txt.textContent = state === "ok" ? "live" : state === "idle" ? "idle" : state === "err" ? "offline" : "connecting";
|
||||
}
|
||||
|
||||
// ---- runtime controls: start/hold, pause/resume, retry ----
|
||||
async function post(path) {
|
||||
try { await fetch(path, { method: "POST" }); } catch (e) {}
|
||||
poll(); // reflect the new state immediately
|
||||
}
|
||||
|
||||
function renderControls(d) {
|
||||
const c = d.current || {};
|
||||
const encoding = c.file && c.phase !== "idle";
|
||||
let html = d.running
|
||||
? `<button class="ctl" data-act="/api/hold">⏸ Hold queue</button>`
|
||||
: `<button class="ctl primary" data-act="/api/start">▶ Start queue</button>`;
|
||||
if (encoding) {
|
||||
html += c.paused
|
||||
? `<button class="ctl primary" data-act="/api/resume">▶ Resume encode</button>`
|
||||
: `<button class="ctl" data-act="/api/pause">⏸ Pause encode</button>`;
|
||||
}
|
||||
const state = !d.running ? "held" : c.paused ? "running · encode paused" : "running";
|
||||
html += `<span class="ctl-state${c.paused ? " paused" : ""}">${state}</span>`;
|
||||
$("controls").innerHTML = html;
|
||||
}
|
||||
|
||||
function renderFailed(failed) {
|
||||
const card = $("failedCard");
|
||||
if (!failed || !failed.length) { card.hidden = true; $("failed").innerHTML = ""; return; }
|
||||
card.hidden = false;
|
||||
$("fcount").textContent = "(" + failed.length + ")";
|
||||
$("failed").innerHTML = failed.map(f =>
|
||||
`<div class="frow"><span class="fn">${esc(f)}</span><button class="ctl small" data-retry="${esc(f)}">retry</button></div>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
$("controls").addEventListener("click", e => {
|
||||
const b = e.target.closest("[data-act]");
|
||||
if (b) post(b.getAttribute("data-act"));
|
||||
});
|
||||
$("failed").addEventListener("click", e => {
|
||||
const b = e.target.closest("[data-retry]");
|
||||
if (b) post("/api/retry?file=" + encodeURIComponent(b.getAttribute("data-retry")));
|
||||
});
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const r = await fetch("status", { cache: "no-store" });
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
const d = await r.json();
|
||||
pushSpeed(d.current);
|
||||
renderControls(d);
|
||||
renderJob(d.current);
|
||||
renderFailed(d.failed);
|
||||
renderQueue(d.queue, d.current);
|
||||
renderEvents(d.recent);
|
||||
const active = d.current && d.current.file && d.current.phase !== "idle";
|
||||
setLive(active ? "ok" : "idle");
|
||||
setLive(d.current && d.current.paused ? "idle" : active ? "ok" : "idle");
|
||||
} catch (e) {
|
||||
setLive("err");
|
||||
} finally {
|
||||
|
||||
+94
-13
@@ -20,15 +20,27 @@ var indexHTML []byte
|
||||
//go:embed settings.html
|
||||
var settingsHTML []byte
|
||||
|
||||
type Server struct {
|
||||
tracker *status.Tracker
|
||||
log *logger.Logger
|
||||
store *settings.Store
|
||||
inputDir string
|
||||
// Controls is the runtime-control surface the UI drives, wired in main from the
|
||||
// watcher (start/hold gate), encoder (pause/resume), and mover (retry).
|
||||
type Controls struct {
|
||||
Running func() bool
|
||||
SetRunning func(bool)
|
||||
Pause func() error
|
||||
Resume func() error
|
||||
RetryFailed func(name string) error
|
||||
}
|
||||
|
||||
func New(tracker *status.Tracker, log *logger.Logger, store *settings.Store, inputDir string) *Server {
|
||||
return &Server{tracker: tracker, log: log, store: store, inputDir: inputDir}
|
||||
type Server struct {
|
||||
tracker *status.Tracker
|
||||
log *logger.Logger
|
||||
store *settings.Store
|
||||
controls Controls
|
||||
inputDir string
|
||||
failedDir string
|
||||
}
|
||||
|
||||
func New(tracker *status.Tracker, log *logger.Logger, store *settings.Store, controls Controls, inputDir, failedDir string) *Server {
|
||||
return &Server{tracker: tracker, log: log, store: store, controls: controls, inputDir: inputDir, failedDir: failedDir}
|
||||
}
|
||||
|
||||
// Handler returns the mux for all status routes. Phase 3 adds "/" (the HTML
|
||||
@@ -38,10 +50,72 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("/status", s.handleStatus)
|
||||
mux.HandleFunc("/settings", s.handleSettingsPage)
|
||||
mux.HandleFunc("/api/settings", s.handleAPISettings)
|
||||
mux.HandleFunc("/api/start", s.gateHandler(true))
|
||||
mux.HandleFunc("/api/hold", s.gateHandler(false))
|
||||
mux.HandleFunc("/api/pause", s.actionHandler(func() error { return s.controls.Pause() }, "Encode paused"))
|
||||
mux.HandleFunc("/api/resume", s.actionHandler(func() error { return s.controls.Resume() }, "Encode resumed"))
|
||||
mux.HandleFunc("/api/retry", s.handleRetry)
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
return mux
|
||||
}
|
||||
|
||||
// gateHandler flips the start/hold gate. POST only.
|
||||
func (s *Server) gateHandler(run bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
s.controls.SetRunning(run)
|
||||
if run {
|
||||
s.log.Info("Queue started via web UI")
|
||||
} else {
|
||||
s.log.Info("Queue held via web UI")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// actionHandler wraps a no-arg control action (pause/resume). POST only.
|
||||
func (s *Server) actionHandler(fn func() error, logMsg string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if err := fn(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.log.Info(logMsg + " via web UI")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRetry moves a named file from failed/ back to input/. POST ?file=NAME.
|
||||
func (s *Server) handleRetry(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
name := r.URL.Query().Get("file")
|
||||
if name == "" {
|
||||
http.Error(w, "missing file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.controls.RetryFailed(name); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.log.Info("Retry requested via web UI: " + name)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func methodNotAllowed(w http.ResponseWriter) {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(settingsHTML)
|
||||
@@ -83,17 +157,14 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Running bool `json:"running"`
|
||||
Current status.Snapshot `json:"current"`
|
||||
Queue []string `json:"queue"`
|
||||
Failed []string `json:"failed"`
|
||||
Recent []logger.LogEntry `json:"recent"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
queue := []string{}
|
||||
for _, f := range watcher.InputFiles(s.inputDir) {
|
||||
queue = append(queue, filepath.Base(f))
|
||||
}
|
||||
|
||||
recent, err := s.log.RecentLogs(50)
|
||||
if err != nil {
|
||||
http.Error(w, "reading logs", http.StatusInternalServerError)
|
||||
@@ -102,8 +173,18 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(statusResponse{
|
||||
Running: s.controls.Running(),
|
||||
Current: s.tracker.Snapshot(),
|
||||
Queue: queue,
|
||||
Queue: baseNames(watcher.InputFiles(s.inputDir)),
|
||||
Failed: baseNames(watcher.InputFiles(s.failedDir)),
|
||||
Recent: recent,
|
||||
})
|
||||
}
|
||||
|
||||
func baseNames(paths []string) []string {
|
||||
out := []string{}
|
||||
for _, p := range paths {
|
||||
out = append(out, filepath.Base(p))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user