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 @@ -113,6 +113,11 @@ public final class FqrController {
private View overlay;
private CircularProgressIndicator overlayRing; // the ring = the percent
private TextView overlayEta, overlayPct; // inside the ring: ETA + percent
// K2GO-317: the highest percent shown so far (client high-water mark) and the blink used while a resume
// re-derives progress. A resume relaunches the runner, which re-counts completed layers from 0, so it
// briefly reports LESS than we already showed. We hold the peak and blink, never fall back to 0 and climb.
private int overlayPeakPct = -1;
private android.animation.ObjectAnimator overlayBlink;
private TextView overlayStatus, overlaySize; // header action word + the "size : speed" line
private View overlayHeader, overlayMeta; // chrome, hidden when minimized
private String overlaySizeStr = ""; // the size half of the "size : speed" line
Expand Down Expand Up @@ -401,18 +406,22 @@ private void showConsent(String name, String box, long transfer, long archive, l
// (startDownload is invoked from the positive button above.)
pendingBox = box;
pendingArchive = archive;
pendingTransfer = transfer;
}

private String pendingBox; // box paired with the name at consent time
private long pendingArchive; // on-disk size (bytes) to show in the overlay header
private long pendingTransfer; // K2GO-317: estimated download bytes (consent "transfer"), sent to the server
private long dlEstBytes; // the estimate in effect for the current download (reused on Retry)

private void startDownload(String name) {
final String box = pendingBox;
if (box == null) return;
dlName = name;
dlBox = box;
dlEstBytes = pendingTransfer;
showOverlay(name, pendingArchive);
client.download(name, box, downloadListener());
client.download(name, box, dlEstBytes, downloadListener());
}

/** K2GO-541: one reusable listener, so Retry can re-invoke the download on the same overlay. The poll
Expand Down Expand Up @@ -440,12 +449,19 @@ private MapsRegionClient.DownloadListener downloadListener() {
overlayStopped = true;
setStopIcon(true);
setStopEnabled(true);
stopPctBlink(); // K2GO-317: a paused ring is steady amber, not blinking
if (overlayStatus != null) overlayStatus.setText(R.string.k2go_dl_paused_notif);
if (overlayEta != null) overlayEta.setText(""); // a paused job must not advertise a time-to-finish
if (overlayRing != null && percent >= 0) overlayRing.setProgressCompat(percent, false);
// K2GO-317: never show less than the peak already reached (a late re-derivation can report lower).
int shown = Math.max(percent, overlayPeakPct);
if (shown >= 0) {
if (overlayRing != null) overlayRing.setProgressCompat(shown, false);
if (overlayPct != null) overlayPct.setText(str(R.string.k2go_fqr_percent, shown));
}
setRingState(cAmber, false); // amber + dimmed: a paused job must not look live
}
@Override public void onDone() {
stopPctBlink(); // K2GO-317: every exit path stops the blink (done, like pause/fail/hide)
setRingState(cLeaf, true); // done: a full leaf ring reads as success
updateOverlay(100, 0, -1);
if (overlayStatus != null) overlayStatus.setText(R.string.k2go_fqr_region_added); // may be gone if minimized
Expand Down Expand Up @@ -550,7 +566,10 @@ private void showOverlay(String name, long sizeBytes) {
overlayEta = new TextView(themed);
overlayEta.setGravity(Gravity.CENTER);
overlayEta.setMaxLines(2);
overlayEta.setMaxWidth(dp(84));
// K2GO-541: narrow enough that "6 s restantes" wraps to two short lines inside the ring : the magnitude
// ("6 s") over the remaining-word ("restantes") : instead of one wide line. Tuned for es/en; a locale
// with a longer remaining-word (de "verbleibend") may need a wider value or per-locale stacked strings.
overlayEta.setMaxWidth(dp(64));
applyOverlayType(overlayEta, R.style.TextAppearance_K2Go_Caption, cOnSurfaceVariant);
overlayEta.setText(R.string.k2go_fqr_starting); // ETA present from the start, held until done
overlayPct = ringText(R.style.TextAppearance_K2Go_Title, cOnSurface);
Expand Down Expand Up @@ -690,14 +709,15 @@ private void showFailed() {
if (overlayStopBtn != null) overlayStopBtn.setContentDescription(str(R.string.k2go_dl_retry));
if (overlayStatus != null) overlayStatus.setText(R.string.k2go_fqr_download_failed);
if (overlayEta != null) overlayEta.setText(""); // no time-to-finish on a stopped job
stopPctBlink(); // K2GO-317: a failed panel must not keep the percent blinking
setRingState(cError, false); // red + dimmed: the job is not running
}

private void retryDownload() {
setStopEnabled(false); // guard a double tap until the first poll or error confirms
clearFailed();
if (overlayEta != null) overlayEta.setText(R.string.k2go_fqr_starting);
client.download(dlName, dlBox, downloadListener());
client.download(dlName, dlBox, dlEstBytes, downloadListener());
}

/** Restore the running look (live teal ring, Pause icon, "Downloading" word) out of the failed state. */
Expand Down Expand Up @@ -786,17 +806,44 @@ private int[] ringCenterOnScreen() {
private void updateOverlay(int percent, long speed, int etaSeconds) {
if (overlayRing == null) return;
if (overlaySize != null) overlaySize.setText(sizeLine(speed)); // "708 MB : 1.3 MB/s"
if (overlayEta != null) {
// K2GO-317: a resume relaunches the runner, which re-derives the overall percent from 0 (it re-counts
// completed layers), so it momentarily reports LESS than we already showed. Do NOT drop the ring back
// to that lower value (it reads as "restarting"): hold the peak and blink the percent to show it is
// still working. When the server climbs back past the peak, resume advancing from there. The size/speed
// line keeps updating throughout, so the card still shows live activity while the number holds.
boolean regressing = (percent >= 0 && percent < overlayPeakPct);
if (overlayEta != null && !regressing) {
String eta = etaPlain(etaSeconds);
if (!eta.isEmpty()) overlayEta.setText(eta); // hold the last known ETA; never blank mid-download
else if (percent >= 100) overlayEta.setText(""); // done: clear the estimate
}
if (percent >= 0) { // hold the ring + percent on an unknown poll
overlayRing.setProgressCompat(percent, true);
overlayPct.setText(str(R.string.k2go_fqr_percent, percent));
if (percent >= 0) {
if (regressing) {
startPctBlink(); // hold peak, blink: a resume is re-deriving progress
} else {
stopPctBlink();
overlayPeakPct = percent;
overlayRing.setProgressCompat(percent, true);
overlayPct.setText(str(R.string.k2go_fqr_percent, percent));
}
}
}

/** K2GO-317: pulse the percent while a resume re-derives progress (the server dips below the peak). It
* signals work in flight without dropping the ring; stopped once progress passes the peak again. */
private void startPctBlink() {
if (overlayBlink != null || overlayPct == null) return;
overlayBlink = android.animation.ObjectAnimator.ofFloat(overlayPct, "alpha", 1f, 0.2f);
overlayBlink.setDuration(450);
overlayBlink.setRepeatMode(android.animation.ValueAnimator.REVERSE);
overlayBlink.setRepeatCount(android.animation.ValueAnimator.INFINITE);
overlayBlink.start();
}
private void stopPctBlink() {
if (overlayBlink != null) { overlayBlink.cancel(); overlayBlink = null; }
if (overlayPct != null) overlayPct.setAlpha(1f);
}

/** The "size : speed" line under the name ("708 MB : 1.3 MB/s"); size alone until the speed arrives. */
private String sizeLine(long speed) {
String r = humanRate(speed);
Expand Down Expand Up @@ -896,6 +943,8 @@ private static String humanRate(long bps) {

private void hideOverlay() {
if (overlay == null) return;
stopPctBlink(); // K2GO-317: cancel the blink before overlayPct is cleared
overlayPeakPct = -1; // the high-water mark is per download; a new overlay starts fresh
ViewGroup parent = (ViewGroup) overlay.getParent();
if (parent != null) parent.removeView(overlay);
overlay = null; overlayRing = null; overlayEta = null; overlayPct = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,16 @@ public void estimate(@NonNull String box, @NonNull EstimateListener l) {
});
}

/** Start the region download and poll it to completion. */
public void download(@NonNull String name, @NonNull String box, @NonNull DownloadListener l) {
/** Start the region download and poll it to completion. estBytes is the consent-step size estimate; the
* server uses it to drive the overall percent and ETA from cumulative bytes (K2GO-317). 0 = unknown. */
public void download(@NonNull String name, @NonNull String box, long estBytes, @NonNull DownloadListener l) {
this.dl = l;
this.finished = false;
this.pollErrors = 0;
AppExecutors.get().io().execute(() -> {
try {
JSONObject item = new JSONObject().put("name", name).put("box", box);
if (estBytes > 0) item.put("est", estBytes); // K2GO-317: total-bytes estimate for overall %/ETA
JSONObject body = new JSONObject().put("items", new JSONArray().put(item));
JSONObject resp = httpJson("POST", BASE + "/download", body);
String id = resp.optString("id", "");
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.5.0** - FQR map download progress and resume resilience (K2GO-317). (1) Overall percent and a smoothed total ETA: the ring and ETA no longer come from go-pmtiles' per-layer numbers. The device sends the consent-step size estimate (`est` on the download item); the maps runner drives the percent from CUMULATIVE bytes vs that total, so each layer takes its true byte share and the ring no longer sticks on the big layer or jumps at layer boundaries (it was `(layersDone + frac) / 3`, equal thirds regardless of layer size). It also computes one whole-region ETA from a SMOOTHED speed (EMA over ~4 s, with a short warm-up) instead of go-pmtiles' raw `eta_s`, which was instantaneous and reset every layer (so the time jumped 1 s to 2 min to 15 s and read "1 s left" at 65%). The reported speed is the smoothed value too. An old job with no estimate falls back to the per-layer math. (2) Pause grace: the SIGTERM grace before a fallback SIGKILL rises from 2.5 s to 60 s, because go-pmtiles' journal flush (an fsync of up to a checkpoint interval of data) is not time-bounded and a short grace could SIGKILL mid-flush and lose progress; the child normally exits on its own right after the flush, so the grace is only a last-resort cap (engine-wide: affects every job's pause). (3) Stall watchdog: it rises from 7 s to 30 s and now counts a change in go-pmtiles' `retries` as activity, so it fires only on a true wedge (no bytes AND no retries) and no longer false-pauses during a plan, a finalize, the next layer's start, or a retry wait : a false pause would re-plan the layer and, before the go-pmtiles skip fix, re-download every completed layer. (4) Frozen-process watchdog: go-pmtiles emits one JSON line per second in every phase, so if no line arrives for 30 s the process is frozen (e.g. storage stopped responding during plan or finalize, which the byte watchdog does not see); the runner then SIGTERMs for the same lossless pause. Both watchdogs turn a hang into a resumable pause, not a lost download. (5) A transient network error in tile-extract.py's own mirror or catalog request (its `requests.head` between layers, which produces no go-pmtiles exit_code 2) is now treated as resumable, not a hard error: the runner keeps every completed layer and marks the job paused, so a resume skips the completed layers (r2) instead of re-downloading them. Phone links blip often, and before this a late-layer blip purged every earlier layer. Localhost-only. (K2GO-317)
- **1.4.12** - FQR downloads retain progress on a network drop, and the resume percent is corrected (K2GO-317). (1) Progress retention: go-pmtiles' own network give-up checkpoints a STALE journal and loses the bytes since its last periodic checkpoint (measured ~2/3 of a large region). The maps runner now watches the progress stream and, after ~7 s with no byte progress, SIGTERMs the download itself so go-pmtiles flushes the CURRENT bytes losslessly, then marks the job `paused` (resumable); a SIGTERM flush resumes from exactly where it stopped (measured), and go-pmtiles' own lossy give-up stays as a fallback. (2) Revert the 1.4.11 percent change: `bytes_done` already includes `resumed_bytes`, so adding it double-counted and clamped a resumed layer to 100%; back to `bytes_done / bytes_total`. Localhost-only. (K2GO-317)
- **1.4.11** - FQR resume progress shows where it resumed, not from zero (K2GO-317). The job percent now counts go-pmtiles' `resumed_bytes` (the bytes continued from the `.resume` journal) in each layer's fraction, so a resumed download picks the ring up near where it stopped instead of appearing to restart at 0%. Display only: the download already resumed from the journal; this stops the ring from misreporting it as a restart. Localhost-only. (K2GO-317)
- **1.4.10** - FQR map downloads resume after a network drop (K2GO-317). A mid-download Wi-Fi drop no longer restarts from zero. The custom go-pmtiles build keeps its `.resume` journal and reports `exit_code` 2 ("network gave up") on its last json line; the maps runner now reads that `exit_code` and, on a stop that is not an engine pause or cancel, keeps the partial and the journal and marks the job `paused` (resumable) instead of purging, so the app's Resume continues from the journal. A hard failure (any other exit) still purges. No new job phase (reuses `paused`) and no rootfs rebake (the signal was already on stdout). Resume needs the box rootfs to carry the m16 pmtiles binary. Localhost-only. (K2GO-317)
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.12",
"version": "1.5.0",
"description": "",
"main": "index.js",
"scripts": {
Expand Down
12 changes: 9 additions & 3 deletions static/dashboard/sockets/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,15 @@ 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;
// K2GO-317: time a paused child gets to flush its resume checkpoint on SIGTERM before killAll
// SIGKILLs a survivor that ignored it. The flush is not bounded: go-pmtiles fsyncs the output (up to
// the periodic-checkpoint interval of unsynced data) before it writes the journal, so a fast link onto
// slow storage can take many seconds. So this is a last-resort cap, not the expected path: the child
// normally exits on its own right after the flush (go-pmtiles ends with phase:paused, exit_code:3) and
// the exit handler resolves before this fires. The job is already shown 'paused' the moment we SIGTERM
// (patch below), so a long cap has no user-visible cost. A SIGKILL during the save does not corrupt: the
// journal is replaced atomically and lists only on-disk data.
const PAUSE_GRACE_MS = 60_000;

class JobManager {
private db: Database.Database;
Expand Down
Loading
Loading