Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,10 @@ public final class FqrController {
private TextView overlayPct, overlayTitle;
private TextView overlayMin;
private boolean overlayMinimized = false;
// ADFA-4896: the Stop/Retry control on the overlay. It drives the server pause/resume endpoints,
// but there is no maps checkpoint yet (resume re-extracts from 0), so we present it honestly as
// Stop -> Retry, not Pause -> Resume. overlayStopped is the last state the poll reported.
// ADFA-4896 / K2GO-317: the Pause/Resume control on the overlay. It drives the server pause/resume
// endpoints; pause stops gracefully (SIGTERM) so pmtiles flushes its resume journal, and resume
// continues from that checkpoint instead of restarting. overlayStopped (true = paused) is the last
// state the poll reported and the SOLE writer of the label.
private com.google.android.material.button.MaterialButton overlayStop;
private boolean overlayStopped = false;

Expand Down Expand Up @@ -370,30 +371,29 @@ private void startDownload(String name) {
if (box == null) return;
showOverlay(name, pendingArchive);
client.download(name, box, new MapsRegionClient.DownloadListener() {
@Override public void onProgress(int percent, long speed) {
if (overlayStopped) { // retried/running again: back to Stop, live teal bar
@Override public void onProgress(int percent, long speed, int etaSeconds) {
if (overlayStopped) { // resumed/running again: back to Pause, live teal bar
overlayStopped = false;
if (overlayStop != null) overlayStop.setText(R.string.k2go_clone_stop_confirm);
if (overlayStop != null) overlayStop.setText(R.string.k2go_dl_pause);
setBarState(cPrimary, true);
}
if (overlayStop != null) overlayStop.setEnabled(true); // re-enable after a Stop/Retry tap
updateOverlay(percent, speed);
if (overlayStop != null) overlayStop.setEnabled(true); // re-enable after a Pause/Resume tap
updateOverlay(percent, speed, etaSeconds);
}
@Override public void onPaused(int percent) {
// Server phase is 'paused', but with no checkpoint this is a full stop: show "Stopped"
// and offer Retry (which re-extracts from 0). No percent -- it would imply a resume
// point that doesn't exist.
// K2GO-317: paused keeps the partial + resume journal (SIGTERM checkpoint), so resume
// continues from here. Show "Paused" at the reached percent and offer Resume; freeze the
// bar at that percent in amber so a paused job never looks live.
overlayStopped = true;
if (overlayStop != null) { overlayStop.setText(R.string.k2go_dl_retry); overlayStop.setEnabled(true); }
if (overlayPct != null) overlayPct.setText(R.string.k2go_card_stopped);
// #2: freeze the bar (determinate, no animation) so it doesn't keep animating under "Stopped".
if (overlayStop != null) { overlayStop.setText(R.string.k2go_dl_resume); overlayStop.setEnabled(true); }
if (overlayPct != null) overlayPct.setText(R.string.k2go_dl_paused_notif);
if (overlayBar != null && overlayBar.isIndeterminate()) setBarMode(false);
if (overlayBar != null && percent >= 0) overlayBar.setProgressCompat(percent, false);
setBarState(cAmber, false); // amber + dimmed: a halted job must not look live
setBarState(cAmber, false); // amber + dimmed: a paused job must not look live
}
@Override public void onDone() {
setBarState(cLeaf, true); // done: a full leaf bar reads as success
updateOverlay(100, 0);
updateOverlay(100, 0, -1);
if (overlayTitle != null) overlayTitle.setText(R.string.k2go_fqr_region_added); // may be gone if hidden
webView.postDelayed(() -> { hideOverlay(); webView.reload(); }, 1200);
}
Expand Down Expand Up @@ -447,18 +447,22 @@ private void showOverlay(String name, long sizeBytes) {
overlayPct = new TextView(themed);
overlayPct.setText(R.string.k2go_fqr_starting);
M3Text.apply(overlayPct, com.google.android.material.R.style.TextAppearance_Material3_BodyMedium, cOnSurfaceVariant);
// K2GO-317: keep percent/speed/ETA on one line next to the buttons; on a narrow width the
// rightmost (ETA) ellipsizes rather than wrapping and growing the row.
overlayPct.setSingleLine(true);
overlayPct.setEllipsize(android.text.TextUtils.TruncateAt.END);
row.addView(overlayPct, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
// ADFA-4896: Stop/Retry beside Cancel. The label follows the reported state; the tap fires the
// ADFA-4896: Pause/Resume beside Cancel. The label follows the reported state; the tap fires the
// matching verb and the poll (onPaused/onProgress) is the source of truth.
overlayStopped = false;
// K2GO-385: download-controls role ladder (design k2go-download-card-style-sizes-v1) -- the
// keep-the-download primary (Stop/Retry) is the FILLED teal button; Cancel, which discards the
// keep-the-download primary (Pause/Resume) is the FILLED teal button; Cancel, which discards the
// transfer, is a TEXT button in clay. Never two equal outline pills. FQR's overlay is a themed
// (day/night) surface, so the app button styles' theme teal/clay are right here -- not the fixed
// boot tokens. The role look lives only in the styles; no colour is set in Java.
ContextThemeWrapper filledCtx = new ContextThemeWrapper(themed, R.style.ThemeOverlay_K2Go_Button_Filled);
overlayStop = new MaterialButton(filledCtx, null);
overlayStop.setText(R.string.k2go_clone_stop_confirm);
overlayStop.setText(R.string.k2go_dl_pause);
overlayStop.setOnClickListener(v -> toggleStop());
row.addView(overlayStop);
ContextThemeWrapper textDangerCtx = new ContextThemeWrapper(themed, R.style.ThemeOverlay_K2Go_Button_Text_Destructive);
Expand All @@ -477,10 +481,10 @@ private void showOverlay(String name, long sizeBytes) {
activity.addContentView(overlay, params);
}

/** ADFA-4896: fire Stop (server pause) or Retry (server resume — re-extracts from 0, no maps
* checkpoint). The poll (onPaused/onProgress) is the SOLE writer of the label/state; here we only
* fire the verb and disable the button until the next poll confirms, so the label never flickers
* between the tap and the server catching up. */
/** ADFA-4896 / K2GO-317: fire Pause (server pause, SIGTERM checkpoint) or Resume (server resume,
* continues from the pmtiles journal). The poll (onPaused/onProgress) is the SOLE writer of the
* label/state; here we only fire the verb and disable the button until the next poll confirms, so
* the label never flickers between the tap and the server catching up. */
private void toggleStop() {
if (overlayStop != null) overlayStop.setEnabled(false);
if (overlayStopped) client.resume(); else client.pause();
Expand All @@ -497,19 +501,28 @@ private void toggleMinimize() {
if (overlayMin != null) overlayMin.setText(overlayMinimized ? R.string.k2go_fqr_show : R.string.k2go_hide);
}

private void updateOverlay(int percent, long speed) {
private void updateOverlay(int percent, long speed, int etaSeconds) {
if (overlayBar == null || overlayPct == null) return;
final String rate = speed > 0 ? " · " + humanRate(speed) : "";
final String eta = etaText(etaSeconds);
if (percent < 0) {
if (!overlayBar.isIndeterminate()) setBarMode(true);
overlayPct.setText(str(R.string.k2go_fqr_working) + rate);
overlayPct.setText(str(R.string.k2go_fqr_working) + rate + eta);
} else {
if (overlayBar.isIndeterminate()) setBarMode(false);
overlayBar.setProgressCompat(percent, true);
overlayPct.setText(str(R.string.k2go_fqr_percent, percent) + rate);
overlayPct.setText(str(R.string.k2go_fqr_percent, percent) + rate + eta);
}
}

/** K2GO-317: compact ETA for the progress line (" · 45 s left" / " · ~3 min"); empty when
* unknown (<0) or done (0). Reuses the shared eta strings, so no new copy or l10n debt. */
private String etaText(int seconds) {
if (seconds <= 0) return "";
if (seconds < 60) return " · " + str(R.string.k2go_eta_sec, seconds);
return " · " + str(R.string.k2go_eta_minutes_fmt, (seconds + 30) / 60);
}

/** Switch the Material progress indicator between indeterminate/determinate. The indicator forbids
* the switch while visible, so toggle visibility around it. */
private void setBarMode(boolean indeterminate) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ public interface EstimateListener {

/** Region download progress (single durable job). */
public interface DownloadListener {
void onProgress(int percent, long speedBytesPerSec); // percent may be -1; speed 0 if unknown
// percent may be -1 (indeterminate); speed 0 and etaSeconds -1 when unknown. K2GO-317: eta.
void onProgress(int percent, long speedBytesPerSec, int etaSeconds);
void onDone();
void onError(String message);
/** ADFA-4894: the job is paused (partial kept on the box); resume() continues it. Default
Expand Down Expand Up @@ -127,7 +128,8 @@ private void pollOnce() {
default: { // queued / downloading / processing: report progress if known
final int p = percent;
final long sp = j.optLong("speed", 0L);
deliver(() -> dl.onProgress(p, sp));
final int eta = j.optInt("eta", -1); // K2GO-317: seconds remaining, -1 unknown
deliver(() -> dl.onProgress(p, sp, eta));
}
}
main.postDelayed(pollTask, POLL_MS);
Expand Down Expand Up @@ -202,8 +204,8 @@ public void pause() {
});
}

/** ADFA-4894: resume a paused download; the server continues it via --continue, and the poll
* reports progress again via onProgress(). */
/** ADFA-4894: resume a paused download; the server continues it from its on-disk checkpoint
* (K2GO-317: the pmtiles resume journal), and the poll reports progress again via onProgress(). */
public void resume() {
final String id = jobId;
if (id == null || id.isEmpty()) return;
Expand Down
1 change: 1 addition & 0 deletions static/dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ One line per version, newest first. Every REST-facing change bumps the version i
(the app surfaces it via `/system/dashboard/update-check` and the "Update available" pill), so this
file is the human record of what each bump enables. Keep entries short: `version - change (TICKET)`.

- **1.4.7** - FQR map downloads pause and resume on the durable job engine (K2GO-317). Pause now sends the extract a graceful SIGTERM, so the custom go-pmtiles build flushes its on-disk resume journal (`<output>.resume`); resume re-runs tile-extract, which continues from that journal instead of re-extracting each layer from zero. The maps runner keeps the partial and the journal on pause, purges both on cancel or any other failure, reads the json progress line by line (a split stdout chunk no longer drops a second of progress), and reports whole-job percent with speed and eta. Resume from the journal needs the box rootfs to carry the m16 pmtiles binary and the patched tile-extract.py. Localhost-only. (K2GO-317)
- **1.4.6** - Rename the content-exchange sigla cx to conex: the relay file is now conex-proxy and the core-port env var is CONEX_PORT (naming only; same /v1 localhost relay to the core on 127.0.0.1:9500). (chore)
- **1.4.5** - Relay to the content-exchange core (conex-core). dash-node now forwards `/v1/*` (reached externally at `/k2go-api/v1/*`) to the in-box conex-core REST service on `127.0.0.1:9500`: GET and POST, including the streamed `GET /v1/export/item`. dash-node only relays; the core owns the content state (one owner per fact). No proxy library (Node core `http`); the core's port is env-overridable via `CONEX_PORT`. Localhost-only. (K2GO-427)
- **1.4.4** - Overall progress + total ETA for the content download jobs (K2GO-503). `downloadWithAria2` now aggregates progress across the WHOLE job through aria2's RPC (piece-accurate `completedLength` summed over tellActive/Waiting/Stopped) instead of reporting the last file's percent, so a multi-file download (code-addons / code-assets) shows one monotonic bar. `GET /:type/jobs/:id` gains an `eta` field (whole-job seconds remaining, -1 unknown) alongside `percent`/`speed`. The code-addons runner passes its staged-catalog total as the denominator so the bar and ETA are accurate from the first tick; code-assets uses aria2's learned totals. aria2 is started with `--enable-rpc` on an ephemeral localhost port and shut down on queue drain (it does not exit on its own under RPC); the console readout stays as the fallback when RPC is unreachable, and pause/cancel still kill the process directly. Localhost-only. (K2GO-503)
Expand Down
2 changes: 1 addition & 1 deletion static/dashboard/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dashboard-console",
"version": "1.4.6",
"version": "1.4.7",
"description": "",
"main": "index.js",
"scripts": {
Expand Down
48 changes: 34 additions & 14 deletions static/dashboard/sockets/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ export function throwIfStopped(ctx: RunnerContext): void {

const DB_PATH = process.env.K2GO_JOBS_DB || '/library/dashboard/jobs.db';
const ACTIVE: JobPhase[] = ['queued', 'downloading', 'indexing', 'processing'];
// K2GO-317: grace a paused child gets to flush its resume checkpoint on SIGTERM before killAll
// SIGKILLs a survivor that ignored it.
const PAUSE_GRACE_MS = 2500;

class JobManager {
private db: Database.Database;
Expand Down Expand Up @@ -192,10 +195,11 @@ class JobManager {
}

/**
* ADFA-4894 (control surface): pause a running job. Stops the runner like cancel — kills its
* children, aborts an in-flight fetch — but marks it 'paused' and, crucially, the runner does NOT
* clean its partial (it checks {@link RunnerContext.isPaused}), so {@link #resume} continues from
* the checkpoint. Only an ACTIVE job can be paused. Idempotent on a non-active job.
* ADFA-4894 (control surface): pause a running job. Stops the runner like cancel, but with
* SIGTERM not SIGKILL (K2GO-317), so a child like pmtiles can flush a complete resume checkpoint
* before it exits; it aborts an in-flight fetch, marks it 'paused', and the runner does NOT clean
* its partial (it checks {@link RunnerContext.isPaused}), so {@link #resume} continues from the
* checkpoint. Only an ACTIVE job can be paused. Idempotent on a non-active job.
*/
pause(id: string): boolean {
const job = this.get(id);
Expand All @@ -204,7 +208,7 @@ class JobManager {
const rt = this.runtime.get(id);
if (rt) {
rt.paused = true;
this.killAll(rt);
this.killAll(rt, 'SIGTERM'); // graceful: let the child flush its resume checkpoint
try { rt.ac.abort(); } catch { /* interrupt an in-flight fetch */ }
}
this.patch(id, { phase: 'paused' });
Expand All @@ -213,8 +217,8 @@ class JobManager {

/**
* ADFA-4894: resume a paused job — re-launch the runner, which continues from its on-disk
* checkpoint (aria2 --continue for kiwix; per-item for books). No-op unless the job is 'paused'
* and nothing is already running for it.
* checkpoint (aria2 --continue for kiwix; per-item for books; the pmtiles resume journal for
* maps). No-op unless the job is 'paused' and nothing is already running for it.
*/
resume(id: string): boolean {
const job = this.get(id);
Expand Down Expand Up @@ -258,17 +262,33 @@ class JobManager {
}

/**
* ADFA-4896: SIGKILL a job's children. A runner that shells through a wrapper (e.g. maps via
* `sudo`) spawns detached, so the wrapper is a process-group leader; we kill the whole group
* ADFA-4896: stop a job's children. A runner that shells through a wrapper (e.g. maps via
* `sudo`) spawns detached, so the wrapper is a process-group leader; we signal the whole group
* (-pid) so the real worker dies with it, not just the wrapper — otherwise the worker is
* reparented and runs to completion in the background (the region ends up added even though the
* job was canceled). For a non-detached child (kiwix's aria2c), -pid names a group that doesn't
* exist and the group kill is a harmless no-op; the direct kill still stops it.
* job was stopped). For a non-detached child (kiwix's aria2c), -pid names a group that doesn't
* exist and the group signal is a harmless no-op; the direct signal still stops it.
*
* K2GO-317: cancel uses SIGKILL (the partial is discarded anyway); pause passes SIGTERM so the
* child flushes a complete resume checkpoint first (a SIGKILL leaves a truncated journal that
* cannot resume). A child that ignores SIGTERM is SIGKILLed after a grace, skipped when the child
* has already exited (p.exitCode/signalCode) so an already-exited process is never re-signaled.
*/
private killAll(rt: { procs: Set<ChildProcess> }): void {
private killAll(rt: { procs: Set<ChildProcess> }, signal: NodeJS.Signals = 'SIGKILL'): void {
for (const p of rt.procs) {
try { if (p.pid) process.kill(-p.pid, 'SIGKILL'); } catch { /* no such group / already gone */ }
try { p.kill('SIGKILL'); } catch { /* already gone */ }
try { if (p.pid) process.kill(-p.pid, signal); } catch { /* no such group / already gone */ }
try { p.kill(signal); } catch { /* already gone */ }
if (signal !== 'SIGKILL') {
// Graceful SIGTERM above let the child flush its resume checkpoint. If it is still
// running after the grace, SIGKILL the group. p.exitCode/signalCode is Node's own
// record of whether this child exited, so a finished child is never re-signaled (no
// reused-PID hit); the group kill is a caught no-op if the group is already gone.
setTimeout(() => {
if (p.exitCode !== null || p.signalCode !== null) return;
try { if (p.pid) process.kill(-p.pid, 'SIGKILL'); } catch { /* gone */ }
try { p.kill('SIGKILL'); } catch { /* gone */ }
}, PAUSE_GRACE_MS).unref();
}
}
}

Expand Down
Loading
Loading