WebSocket job progress + mobile-responsive web UI
WebSocket progress
- New `/ws/progress` route using axum 0.7's `ws` feature. Upgrades to
a WebSocket that streams the same `ProgressResponse` payload as the
HTTP polling endpoint, pushed instantly whenever a job moves.
- `WebState.progress_tx` is a `tokio::sync::broadcast::Sender<String>`
(capacity 16, lossy — Lagged subscribers resync on the next tick).
- A background tokio task ticks every 500 ms while any download is
running or queued, every 5 s when idle. Skips the JSON encode when
no subscribers are listening, so a single-user install pays zero
CPU for the broadcast when the browser tab is closed.
- JS replaces the per-interval HTTP poll with a WebSocket connection.
The polling path stays as fallback for mobile carriers / reverse
proxies that strip WS — when the socket closes or fails to open,
schedulePoll() resumes immediately and a reconnect attempt fires
after 5 s.
- Initial snapshot is sent right after subscribe so the UI is live
before the next tick.
Mobile-responsive web UI
- Existing `@media(max-width:640px)` block tightened:
- Hide the H1, AGPL footer notice, sort dropdown, and library-size
counter — they don't fit and aren't actionable.
- Header buttons get min 34×34 hit targets (Apple HIG / Material).
- `.card-foot` wraps and grows to 34×34 buttons so the five action
icons (play / watched / favourite / bookmark / waiting) stay
tappable instead of cramming into thumb-unfriendly 22 px chips.
- Modals go full-screen (no 10 px page-margin) so the iPhone safe
area doesn't crop content.
- Footer (URL bar + download button) wraps onto two rows so neither
squeezes the other off-screen.
- Bulk-actions toolbar wraps and gets bigger buttons.
- Per-job stdout preview hides on small screens.
- New `@media(max-width:380px)` collapses the grid to single column
for very narrow phones.
55 unit tests still pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
de122817af
commit
9e64297da6
4 changed files with 251 additions and 15 deletions
57
Cargo.lock
generated
57
Cargo.lock
generated
|
|
@ -470,6 +470,7 @@ dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"axum-macros",
|
"axum-macros",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
|
|
@ -488,8 +489,10 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
@ -528,6 +531,12 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64ct"
|
name = "base64ct"
|
||||||
version = "1.8.3"
|
version = "1.8.3"
|
||||||
|
|
@ -642,6 +651,12 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder-lite"
|
name = "byteorder-lite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -940,6 +955,12 @@ version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
|
|
@ -3684,6 +3705,18 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
|
|
@ -3854,6 +3887,24 @@ version = "0.25.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.8.6",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "type-map"
|
name = "type-map"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|
@ -3929,6 +3980,12 @@ version = "2.1.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
r2d2 = "0.8"
|
r2d2 = "0.8"
|
||||||
r2d2_sqlite = "0.24"
|
r2d2_sqlite = "0.24"
|
||||||
notify-rust = { version = "4", default-features = false, features = ["z"] }
|
notify-rust = { version = "4", default-features = false, features = ["z"] }
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros", "ws"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
|
|
||||||
111
src/web.rs
111
src/web.rs
|
|
@ -109,6 +109,12 @@ pub struct WebState {
|
||||||
/// Per-IP failure tracker for [`post_login`]. Each entry is the number of
|
/// Per-IP failure tracker for [`post_login`]. Each entry is the number of
|
||||||
/// recent failures and the instant the lockout (if any) expires.
|
/// recent failures and the instant the lockout (if any) expires.
|
||||||
pub login_attempts: Mutex<HashMap<std::net::IpAddr, LoginAttempt>>,
|
pub login_attempts: Mutex<HashMap<std::net::IpAddr, LoginAttempt>>,
|
||||||
|
/// Push channel for `/ws/progress` subscribers. A background tokio task
|
||||||
|
/// ticks every 500 ms while jobs are active and broadcasts a fresh
|
||||||
|
/// [`ProgressResponse`] snapshot here; the WebSocket handler forwards
|
||||||
|
/// each message to its client. The `/api/progress` HTTP endpoint stays
|
||||||
|
/// available as a fallback for clients that can't open a socket.
|
||||||
|
pub progress_tx: tokio::sync::broadcast::Sender<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Failed-login tracking entry. After [`LOGIN_LOCKOUT_AFTER`] failures from
|
/// Failed-login tracking entry. After [`LOGIN_LOCKOUT_AFTER`] failures from
|
||||||
|
|
@ -996,6 +1002,68 @@ async fn get_music(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
Json(serde_json::json!({ "tracks": track_infos }))
|
Json(serde_json::json!({ "tracks": track_infos }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /ws/progress` — WebSocket upgrade that streams the same
|
||||||
|
/// [`ProgressResponse`] payload as the polling HTTP endpoint, pushed
|
||||||
|
/// from the background broadcast task. Clients should treat the JSON
|
||||||
|
/// payload identically to `/api/progress`.
|
||||||
|
///
|
||||||
|
/// On socket close (auth lost, network drop, server restart) the JS
|
||||||
|
/// client falls back to polling `/api/progress` so users with broken
|
||||||
|
/// WebSocket setups (some reverse proxies, certain mobile carriers)
|
||||||
|
/// still see updates.
|
||||||
|
async fn ws_progress(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
ws: axum::extract::ws::WebSocketUpgrade,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
ws.on_upgrade(move |socket| ws_progress_handler(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ws_progress_handler(
|
||||||
|
mut socket: axum::extract::ws::WebSocket,
|
||||||
|
state: Arc<WebState>,
|
||||||
|
) {
|
||||||
|
use axum::extract::ws::Message;
|
||||||
|
// Subscribe before sending the initial snapshot so we don't miss a
|
||||||
|
// broadcast that fires between the snapshot build and the subscribe.
|
||||||
|
let mut rx = state.progress_tx.subscribe();
|
||||||
|
// Initial snapshot so the UI shows the current state immediately
|
||||||
|
// (without waiting up to 5s for the next idle broadcast tick).
|
||||||
|
let initial = {
|
||||||
|
let mut dl = state.downloader.lock().unwrap();
|
||||||
|
dl.poll();
|
||||||
|
let queued = dl.pending_snapshots().into_iter()
|
||||||
|
.map(|(label, url)| QueuedSnapshot { label, url })
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
ProgressResponse {
|
||||||
|
jobs: WebState::job_snapshots(&dl),
|
||||||
|
queued,
|
||||||
|
max_concurrent: dl.max_concurrent,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string(&initial) {
|
||||||
|
if socket.send(Message::Text(json)).await.is_err() { return; }
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = rx.recv() => match msg {
|
||||||
|
Ok(json) => {
|
||||||
|
if socket.send(Message::Text(json)).await.is_err() { break; }
|
||||||
|
}
|
||||||
|
// Subscriber lagged. Resync via the next broadcast tick.
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(_) => break,
|
||||||
|
},
|
||||||
|
// Drain inbound frames (mostly pongs / client closes) so the
|
||||||
|
// socket doesn't stall.
|
||||||
|
frame = socket.recv() => match frame {
|
||||||
|
Some(Ok(Message::Close(_))) | None => break,
|
||||||
|
Some(Err(_)) => break,
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_progress(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
async fn get_progress(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
let mut dl = state.downloader.lock().unwrap();
|
let mut dl = state.downloader.lock().unwrap();
|
||||||
dl.poll();
|
dl.poll();
|
||||||
|
|
@ -2021,6 +2089,10 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
|
|
||||||
let password_required_initial = db.get_setting("password_hash").ok().flatten().is_some();
|
let password_required_initial = db.get_setting("password_hash").ok().flatten().is_some();
|
||||||
let flags = db.get_video_flags().unwrap_or_default();
|
let flags = db.get_video_flags().unwrap_or_default();
|
||||||
|
// Capacity 16 is plenty — only a small number of browser tabs ever
|
||||||
|
// subscribe and the broadcast is lossy by design (subscribers that lag
|
||||||
|
// get a Lagged error and just resubscribe).
|
||||||
|
let (progress_tx, _initial_rx) = tokio::sync::broadcast::channel::<String>(16);
|
||||||
let state = Arc::new(WebState {
|
let state = Arc::new(WebState {
|
||||||
library: Mutex::new(library),
|
library: Mutex::new(library),
|
||||||
downloader: Mutex::new(downloader),
|
downloader: Mutex::new(downloader),
|
||||||
|
|
@ -2037,9 +2109,47 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
last_scheduled_check: Mutex::new(None),
|
last_scheduled_check: Mutex::new(None),
|
||||||
password_required_cache: AtomicBool::new(password_required_initial),
|
password_required_cache: AtomicBool::new(password_required_initial),
|
||||||
library_version: AtomicU64::new(1),
|
library_version: AtomicU64::new(1),
|
||||||
|
progress_tx,
|
||||||
login_attempts: Mutex::new(HashMap::new()),
|
login_attempts: Mutex::new(HashMap::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Broadcast progress snapshots to WebSocket subscribers. Ticks fast
|
||||||
|
// (every 500 ms) while any download is active or queued; slow
|
||||||
|
// (every 5 s) when idle, just to keep clients refreshed if state
|
||||||
|
// changed via direct flag/option/folder edits.
|
||||||
|
let progress_state = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
// Pick interval based on whether there's anything to report on.
|
||||||
|
let interval_ms = {
|
||||||
|
let dl = progress_state.downloader.lock().unwrap();
|
||||||
|
if dl.any_running() || dl.pending_count() > 0 { 500 } else { 5_000 }
|
||||||
|
};
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(interval_ms)).await;
|
||||||
|
// Skip broadcast when no one's listening — saves the JSON encode.
|
||||||
|
if progress_state.progress_tx.receiver_count() == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Drive the same poll() that /api/progress does so the snapshot
|
||||||
|
// includes the latest stdout/stderr lines.
|
||||||
|
let snapshot = {
|
||||||
|
let mut dl = progress_state.downloader.lock().unwrap();
|
||||||
|
dl.poll();
|
||||||
|
let queued = dl.pending_snapshots().into_iter()
|
||||||
|
.map(|(label, url)| QueuedSnapshot { label, url })
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
ProgressResponse {
|
||||||
|
jobs: WebState::job_snapshots(&dl),
|
||||||
|
queued,
|
||||||
|
max_concurrent: dl.max_concurrent,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string(&snapshot) {
|
||||||
|
let _ = progress_state.progress_tx.send(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Background scheduler — ticks every 60 s; runs channel checks when due.
|
// Background scheduler — ticks every 60 s; runs channel checks when due.
|
||||||
let sched_state = state.clone();
|
let sched_state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|
@ -2081,6 +2191,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
.route("/", get(get_index))
|
.route("/", get(get_index))
|
||||||
.route("/api/library", get(get_library))
|
.route("/api/library", get(get_library))
|
||||||
.route("/api/progress", get(get_progress))
|
.route("/api/progress", get(get_progress))
|
||||||
|
.route("/ws/progress", get(ws_progress))
|
||||||
.route("/api/download", post(post_download))
|
.route("/api/download", post(post_download))
|
||||||
.route("/api/watched/:id", post(post_watched))
|
.route("/api/watched/:id", post(post_watched))
|
||||||
.route("/api/videos/:id/flags/:flag", post(post_video_flag))
|
.route("/api/videos/:id/flags/:flag", post(post_video_flag))
|
||||||
|
|
|
||||||
|
|
@ -106,13 +106,44 @@
|
||||||
aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px}
|
aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px}
|
||||||
aside.open{transform:translateX(0)}
|
aside.open{transform:translateX(0)}
|
||||||
#sidebar-overlay.open{display:block}
|
#sidebar-overlay.open{display:block}
|
||||||
#hdr-stats,#sort{display:none}
|
/* Hide non-essential header chrome on mobile. The remaining
|
||||||
|
buttons — menu, search, ⟳ rescan, 🎲 shuffle, ⚙ settings —
|
||||||
|
are the ones users actually reach for. */
|
||||||
|
#hdr-stats,#sort,#agpl-notice{display:none}
|
||||||
|
header h1{display:none}
|
||||||
|
header{padding:6px 8px;gap:6px}
|
||||||
|
header button{min-width:34px;min-height:34px;padding:4px 7px;font-size:14px}
|
||||||
|
/* Bigger tap targets on cards. The five-button card-foot reflows
|
||||||
|
to a comfortable height instead of cramming icons together. */
|
||||||
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
|
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
|
||||||
#details{max-height:160px}
|
.card-foot{padding:6px 6px 8px;gap:4px;flex-wrap:wrap}
|
||||||
.chapters-pane{display:none}
|
.card-foot button{font-size:13px;padding:6px 8px;min-height:34px}
|
||||||
|
.card-foot button:not(.play){min-width:34px}
|
||||||
|
.card-meta{font-size:10px}
|
||||||
|
/* Modals go full-screen so the cramped 10px page-margin we use on
|
||||||
|
desktop doesn't eat the iPhone safe area. */
|
||||||
|
.modal-bg{padding:0}
|
||||||
|
.modal{max-width:100vw;max-height:100vh;border-radius:0;border:none}
|
||||||
.modal video{max-height:55vh}
|
.modal video{max-height:55vh}
|
||||||
|
#details{max-height:160px;padding:8px 10px}
|
||||||
|
.chapters-pane{display:none}
|
||||||
section#content{padding:8px}
|
section#content{padding:8px}
|
||||||
footer{padding:6px 10px}
|
/* Download bar wraps onto two rows so the URL input + button
|
||||||
|
don't squeeze each other off-screen. */
|
||||||
|
footer{flex-wrap:wrap;padding:6px 10px;gap:6px}
|
||||||
|
footer input{flex-basis:100%;order:-1}
|
||||||
|
/* Bulk-mode actions stack vertically when active. */
|
||||||
|
.toolbar{flex-wrap:wrap}
|
||||||
|
#bulk-actions{flex-wrap:wrap;gap:4px}
|
||||||
|
#bulk-actions button{padding:6px 8px;min-height:32px;font-size:12px}
|
||||||
|
/* Job rows: hide the last-line preview on small screens; the
|
||||||
|
progress bar + label are the only things that fit anyway. */
|
||||||
|
.job{padding:4px 10px;font-size:11px}
|
||||||
|
.job .last-line{display:none}
|
||||||
|
}
|
||||||
|
@media(max-width:380px){
|
||||||
|
/* Single-column on very narrow phones. */
|
||||||
|
.grid{grid-template-columns:1fr;gap:6px}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -1490,7 +1521,7 @@ function renderJobs(jobs,queued,maxConcurrent){
|
||||||
<span class="badge ${j.state}">${j.state}</span>
|
<span class="badge ${j.state}">${j.state}</span>
|
||||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.label)} — ${esc(j.url)}</span>
|
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.label)} — ${esc(j.url)}</span>
|
||||||
${j.state==='running'?`<progress value="${j.progress}" max="1"></progress>`:''}
|
${j.state==='running'?`<progress value="${j.progress}" max="1"></progress>`:''}
|
||||||
<span style="font-size:11px;color:var(--muted);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.last_line)}</span>
|
<span class="last-line" style="font-size:11px;color:var(--muted);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.last_line)}</span>
|
||||||
${dismiss}
|
${dismiss}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('')+queuedHtml+limitNote;
|
}).join('')+queuedHtml+limitNote;
|
||||||
|
|
@ -1498,25 +1529,62 @@ function renderJobs(jobs,queued,maxConcurrent){
|
||||||
async function clearFinishedJobs(){try{await api('/api/jobs/clear',{method:'POST'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}}
|
async function clearFinishedJobs(){try{await api('/api/jobs/clear',{method:'POST'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}}
|
||||||
async function removeJob(idx){try{await api('/api/jobs/'+idx,{method:'DELETE'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}}
|
async function removeJob(idx){try{await api('/api/jobs/'+idx,{method:'DELETE'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}}
|
||||||
|
|
||||||
|
// Progress updates come over a WebSocket (`/ws/progress`) when available
|
||||||
|
// and fall back to polling `/api/progress` if the upgrade fails (mobile
|
||||||
|
// carriers / reverse proxies that strip WS sometimes do this). The two
|
||||||
|
// paths share `applyProgressSnapshot` so the rendering logic is single-
|
||||||
|
// sourced.
|
||||||
let wasRunning=false;
|
let wasRunning=false;
|
||||||
|
let progressWs=null;
|
||||||
|
let pollTimer=null;
|
||||||
|
function applyProgressSnapshot(d){
|
||||||
|
if(!d)return;
|
||||||
|
renderJobs(d.jobs,d.queued,d.max_concurrent);
|
||||||
|
const run=d.jobs.some(j=>j.state==='running')||(d.queued&&d.queued.length>0);
|
||||||
|
if(wasRunning&&!run)loadLibrary();
|
||||||
|
wasRunning=run;
|
||||||
|
}
|
||||||
async function pollProgress(){
|
async function pollProgress(){
|
||||||
try{
|
try{
|
||||||
const d=await(await api('/api/progress')).json();
|
const d=await(await api('/api/progress')).json();
|
||||||
renderJobs(d.jobs,d.queued,d.max_concurrent);
|
applyProgressSnapshot(d);
|
||||||
const run=d.jobs.some(j=>j.state==='running')||(d.queued&&d.queued.length>0);
|
schedulePoll(wasRunning?600:5000);
|
||||||
if(wasRunning&&!run)await loadLibrary();
|
|
||||||
wasRunning=run;
|
|
||||||
// Poll fast while something's happening, slow when idle. Halves request
|
|
||||||
// count + JSON parses + battery use when the tab sits open all day.
|
|
||||||
schedulePoll(run?600:5000);
|
|
||||||
}catch{schedulePoll(2000)}
|
}catch{schedulePoll(2000)}
|
||||||
}
|
}
|
||||||
let pollTimer=null;
|
|
||||||
function schedulePoll(ms){
|
function schedulePoll(ms){
|
||||||
if(pollTimer)clearTimeout(pollTimer);
|
if(pollTimer)clearTimeout(pollTimer);
|
||||||
pollTimer=setTimeout(pollProgress,ms);
|
pollTimer=setTimeout(pollProgress,ms);
|
||||||
}
|
}
|
||||||
|
function startProgressFeed(){
|
||||||
|
// Build the ws URL relative to the current page so the same code works
|
||||||
|
// for http/https + custom port + reverse-proxy mounts.
|
||||||
|
const proto=location.protocol==='https:'?'wss:':'ws:';
|
||||||
|
const url=`${proto}//${location.host}/ws/progress`;
|
||||||
|
try{
|
||||||
|
progressWs=new WebSocket(url);
|
||||||
|
}catch{
|
||||||
schedulePoll(600);
|
schedulePoll(600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
progressWs.onopen=()=>{
|
||||||
|
// Stop the polling timer once the socket's live — pushes are
|
||||||
|
// authoritative until the connection drops.
|
||||||
|
if(pollTimer){clearTimeout(pollTimer);pollTimer=null}
|
||||||
|
};
|
||||||
|
progressWs.onmessage=(ev)=>{
|
||||||
|
try{applyProgressSnapshot(JSON.parse(ev.data))}catch{}
|
||||||
|
};
|
||||||
|
const onClose=()=>{
|
||||||
|
progressWs=null;
|
||||||
|
// Reconnect attempt after a short backoff; HTTP polling fills in
|
||||||
|
// the gap so the UI never goes blank.
|
||||||
|
schedulePoll(600);
|
||||||
|
setTimeout(()=>{if(!progressWs)startProgressFeed()},5000);
|
||||||
|
};
|
||||||
|
progressWs.onclose=onClose;
|
||||||
|
progressWs.onerror=onClose;
|
||||||
|
}
|
||||||
|
startProgressFeed();
|
||||||
|
|
||||||
/* ── Keyboard shortcuts ─────────────────────────────────────────── */
|
/* ── Keyboard shortcuts ─────────────────────────────────────────── */
|
||||||
// Listed in the ? help dialog below. Skip when the user is typing in a
|
// Listed in the ? help dialog below. Skip when the user is typing in a
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue