diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4d3cb48..b1ecf5a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -32,6 +32,16 @@ pub async fn release_run( launcher.release_run(password, with_api, with_mcp).await } +#[tauri::command] +pub async fn refresh_run( + launcher: State<'_, Launcher>, + password: String, + pg_password: String, + tile_root: String, +) -> CmdResult<()> { + launcher.refresh_run(password, pg_password, tile_root).await +} + #[tauri::command] pub fn release_console(launcher: State<'_, Launcher>, since: usize) -> CmdResult { Ok(launcher.release_console(since)) diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index 6f626ca..794b1ae 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -2539,6 +2539,32 @@ impl Launcher { .map(|_| ()) } + /// Refresh the pack's data payload in its own run. Returns once it has started. + pub async fn refresh_run(&self, password: String, pg_password: String, tile_root: String) -> Result<(), String> { + if self.release_console.lock().is_ok_and(|c| c.is_running()) { + return Err("Something is already running: wait for it to finish.".to_string()); + } + if password.trim().is_empty() { + return Err("The node's sudo password is needed: the dump runs there.".to_string()); + } + let (dev_root, install_dir) = { + let inner = self.inner.lock().await; + (inner.settings.dev_root.trim().to_string(), inner.settings.install_path()) + }; + if dev_root.is_empty() { + return Err("Dev mode has no checkout folder, so the refresh scripts cannot be found.".to_string()); + } + crate::release::run_refresh( + &PathBuf::from(dev_root).join("ODIAN_MCP_Server"), + &install_dir.join("state").join("release"), + pg_password.trim(), + Some(tile_root.as_str()), + &password, + &self.release_console, + ) + .map(|_| ()) + } + /// Everything the release has printed since line `since`. pub fn release_console(&self, since: usize) -> crate::release::ConsoleTail { match self.release_console.lock() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a77efc1..ad96139 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -60,6 +60,7 @@ pub fn run() { commands::release_plan, commands::release_run, commands::release_console, + commands::refresh_run, commands::get_market, commands::apply_market, commands::check_market, diff --git a/src-tauri/src/release.rs b/src-tauri/src/release.rs index 99edb6a..4612dd3 100644 --- a/src-tauri/src/release.rs +++ b/src-tauri/src/release.rs @@ -138,9 +138,14 @@ pub struct ReleaseData { pub tile_bytes: u64, /// Every distinct dataset the tile sets name. pub tile_datasets: Vec, - /// The age of the OLDEST dated dataset, which is what decides how stale the maps are. A - /// name carrying no date (`planet_heightmap`) is not counted: that one does not move. - pub tiles_age_days: Option, + /// The basemap's SOURCE vintage, as `YYYYMMDD`, taken from the dataset name + /// (`protomaps_20260423`). This is the date of the upstream extract the maps were drawn + /// from - NOT when anyone baked or froze them. Nothing in a tile index or a manifest + /// records a bake time, so that age simply does not exist to be shown, and calling this + /// one "old" read as if the pack were stale when it only means the world data is from + /// April. An undated name (`planet_heightmap`) is skipped: that set does not move. + pub basemap_vintage: Option, + pub basemap_vintage_days: Option, } /// Days from 1970-01-01 to y-m-d, proleptic Gregorian. Hinnant's algorithm, which is exact @@ -201,11 +206,13 @@ pub fn data_from_manifest(m: &serde_json::Value, version: &str, is_live: bool, n } } } - let tiles_age_days = tile_datasets + // The oldest dated extract, since that is the one that limits what the maps show. + let basemap_vintage = tile_datasets .iter() .filter_map(|d| dataset_stamp(d)) - .filter_map(|s| age_days(s, now_secs)) - .max(); + .filter_map(|s| age_days(s, now_secs).map(|age| (age, s.to_string()))) + .max_by_key(|(age, _)| *age) + .map(|(_, s)| s); ReleaseData { version: version.to_string(), is_live, @@ -215,7 +222,8 @@ pub fn data_from_manifest(m: &serde_json::Value, version: &str, is_live: bool, n tile_sets: sets.map(|s| s.len()).unwrap_or(0), tile_bytes, tile_datasets, - tiles_age_days, + basemap_vintage_days: basemap_vintage.as_deref().and_then(|s| age_days(s, now_secs)), + basemap_vintage, } } @@ -441,18 +449,78 @@ pub fn run_release( // No `pause` here any more: this runs into the launcher's own panel, where there is no // console to press Enter in. The exit code carries the verdict instead - swallowing a // failure and exiting 0 would make a failed release look like a finished one. - let ps = format!( + let ps = console_script(&body, &log, "Release finished."); + + spawn_console(work_dir, "release.ps1", &ps, &[("ODIAN_NODE_SUDO", sudo)], console) +} + +/// Refresh the data a pack carries: the database seed, and optionally the tile indexes. +/// +/// Deliberately NOT part of a release, and deliberately its own press. The seed is a +/// per-table dump of a database operators are using, and the tiles are indexed where the +/// tiles are. Neither should ride along with shipping a code change - but both should be one +/// click when refreshing the data IS the intent, which is what this is. +/// +/// It only prepares the inputs. Cutting a version from them stays a separate step, so a +/// refresh can be looked at before anything is published. +pub fn run_refresh( + mcp_repo: &Path, + work_dir: &Path, + pg_password: &str, + tile_root: Option<&str>, + sudo: &str, + console: &Shared, +) -> Result { + let script = mcp_repo.join("scripts").join("refresh_data.ps1"); + if !script.is_file() { + return Err(format!("refresh_data.ps1 is not in {}. Pull ODIAN_MCP_Server on this PC.", mcp_repo.join("scripts").display())); + } + let log = work_dir.join("refresh.log"); + let mut body = String::new(); + if !pg_password.is_empty() { + body.push_str(&format!( + "Write-Host '=== Database seed ===' -ForegroundColor Cyan + & '{}' -Seed -PgPassword $pgpw -NodeSudoPw $pw + if ($LASTEXITCODE -ne 0) {{ throw 'the seed refresh failed' }} +", + script.display() + )); + } + if let Some(root) = tile_root.filter(|r| !r.trim().is_empty()) { + body.push_str(&format!( + "Write-Host '=== Tile indexes ===' -ForegroundColor Cyan + & '{}' -Tiles -TileRoot '{}' -NodeSudoPw $pw + if ($LASTEXITCODE -ne 0) {{ throw 'the tile refresh failed' }} +", + script.display(), + root.trim() + )); + } + if body.is_empty() { + return Err("Nothing to refresh: give the database password, a tile folder, or both.".to_string()); + } + let ps = console_script(&body, &log, "Refresh finished. Nothing is published yet."); + spawn_console(work_dir, "refresh.ps1", &ps, &[("ODIAN_NODE_SUDO", sudo), ("ODIAN_PG_PW", pg_password)], console) +} + +/// Wrap a body so a step that throws cannot take the window with it, and everything said is +/// also on disk. No `pause`: this runs into the launcher's own panel, where there is nothing +/// to press Enter with, and the exit code carries the verdict instead. +fn console_script(body: &str, log: &Path, done: &str) -> String { + format!( "$ErrorActionPreference = 'Stop' # The pipe gets UTF-8 or Windows' own French messages arrive as mojibake. [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false $pw = $env:ODIAN_NODE_SUDO $env:ODIAN_NODE_SUDO = $null + $pgpw = $env:ODIAN_PG_PW + $env:ODIAN_PG_PW = $null Start-Transcript -Path '{}' -Force | Out-Null $ok = $true try {{ {} Write-Host '' - Write-Host 'Release finished.' + Write-Host '{}' }} catch {{ Write-Host '' Write-Host \"FAILED: $_\" @@ -466,16 +534,31 @@ if (-not $ok) {{ exit 1 }} ", log.display(), body, + done, log.display() - ); + ) +} +/// Write `ps` and run it with its output going to the console panel. +/// +/// Secrets go in the ENVIRONMENT, never the command line: arguments are readable by anyone +/// who can list processes. +fn spawn_console( + work_dir: &Path, + name: &str, + ps: &str, + env: &[(&str, &str)], + console: &Shared, +) -> Result { std::fs::create_dir_all(work_dir).map_err(|e| format!("cannot use {}: {e}", work_dir.display()))?; - let file = work_dir.join("release.ps1"); + let file = work_dir.join(name); std::fs::write(&file, ps).map_err(|e| format!("cannot write {}: {e}", file.display()))?; let mut cmd = Command::new("powershell"); cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]).arg(&file); - cmd.env("ODIAN_NODE_SUDO", sudo); + for (k, v) in env { + cmd.env(k, v); + } // Nothing can type into this, so stdin is closed: a script that ever asks a question // then fails at once instead of hanging forever with no window to answer in. cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -587,7 +670,10 @@ mod tests { assert_eq!(d.tile_bytes, 100); // Deduplicated, and the undated set does not drag the age to None. assert_eq!(d.tile_datasets, vec!["protomaps_20260423".to_string(), "planet_heightmap".to_string()]); - assert_eq!(d.tiles_age_days, Some(161)); + // The dataset name carries the upstream extract's date, not a bake time - there is no + // bake time anywhere in a tile index or a manifest, which is why none is shown. + assert_eq!(d.basemap_vintage.as_deref(), Some("20260423")); + assert_eq!(d.basemap_vintage_days, Some(161)); // A manifest with no tiles at all must not panic or invent numbers. let empty = data_from_manifest(&serde_json::json!({}), "0.1.0", false, now); diff --git a/src/components/ReleasePanel.svelte b/src/components/ReleasePanel.svelte index 39dd1a7..019fcf8 100644 --- a/src/components/ReleasePanel.svelte +++ b/src/components/ReleasePanel.svelte @@ -25,6 +25,21 @@ /** The short version of a row's state, for the column that has room for three words. NOT called `state`: that shadows the `$state` rune in this file, and everything declared with it silently stops being reactive. */ + // The refresh is its own press, with its own password: it dumps a database operators are + // using, so it must not be reachable by the same click that ships a code change. + let refreshOpen = $state(false); + let pgPassword = $state(""); + let tileRoot = $state(""); + const canRefresh = $derived( + password.trim().length > 0 && (pgPassword.trim().length > 0 || tileRoot.trim().length > 0), + ); + + async function refresh() { + if (!canRefresh || busy) return; + await launcher.refreshRun(password, pgPassword, tileRoot); + pgPassword = ""; + } + /** `20260921T125618Z` -> `21 Sep 2026`; anything else is shown as it came. */ function stamped(snapshot: string | null): string { if (!snapshot || snapshot.length < 8) return snapshot ?? "unknown"; @@ -36,6 +51,13 @@ return `${d} ${MONTHS[m - 1] ?? "?"} ${y}`; } + /** `20260423` -> `Apr 2026`. The vintage of the extract, not an age. */ + function monthOf(stamp: string): string { + const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const m = Number(stamp.slice(4, 6)); + return `${MONTHS[m - 1] ?? "?"} ${stamp.slice(0, 4)}`; + } + function aged(days: number | null): string { if (days === null) return ""; if (days <= 0) return "today"; @@ -97,10 +119,55 @@
Map tiles {d.tile_sets} sets - 90}>{aged(d.tiles_age_days)} + + 365}> + {d.basemap_vintage ? `map data ${monthOf(d.basemap_vintage)}` : ""} + {formatBytes(d.tile_bytes)}
{d.tile_datasets.join(" · ")} + + Tiles record no bake date, so only the source extract's vintage is known. + +
+ +
+ {#if refreshOpen} +
+

+ This rebuilds the seed from the live database and re-indexes the tiles. It + publishes nothing — it prepares the inputs, and the console says how to cut a + version from them. +

+ + +
+ + The seed is minutes of load on a database people are using. Uses the node password above. + + +
+
+ {/if} {#if !d.is_live} Read from this PC's {d.version}, which is not what the DHP serves. {/if} @@ -189,6 +256,33 @@ .payload-age.stale { color: var(--warn); } + .payload-act { + margin-top: 6px; + } + .refresh { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--line); + } + .refresh-why, + .refresh-note { + margin: 0; + color: var(--muted); + font-size: var(--text-xs); + line-height: 1.5; + } + .refresh .row { + display: flex; + align-items: center; + gap: 10px; + } + .refresh .row .refresh-note { + flex: 1; + } + .payload-foot { margin-top: 4px; color: var(--muted); diff --git a/src/lib/backend.ts b/src/lib/backend.ts index c39c8db..5cace7e 100644 --- a/src/lib/backend.ts +++ b/src/lib/backend.ts @@ -75,6 +75,7 @@ function createTauriBackend(): Backend { releasePlan: () => invoke("release_plan"), releaseRun: (password, withApi, withMcp) => invoke("release_run", { password, withApi, withMcp }), releaseConsole: (since) => invoke("release_console", { since }), + refreshRun: (password, pgPassword, tileRoot) => invoke("refresh_run", { password, pgPassword, tileRoot }), onTileSyncProgress: (handler) => subscribe("tile-sync-progress", handler), onRemoveProgress: (handler) => subscribe("remove-progress", handler), onStack: (handler) => subscribe("stack", handler), diff --git a/src/lib/launcher.svelte.ts b/src/lib/launcher.svelte.ts index 25cb79a..6afd2e6 100644 --- a/src/lib/launcher.svelte.ts +++ b/src/lib/launcher.svelte.ts @@ -469,6 +469,20 @@ class Launcher { } } + /** Rebuild the pack's data payload. Streams into the same console as a release. */ + refreshRun(password: string, pgPassword: string, tileRoot: string) { + return this.run("release", async () => { + await backend.refreshRun(password, pgPassword, tileRoot); + this.releaseOpen = false; + this.consoleLines = []; + this.consoleTotal = 0; + this.consoleExit = null; + this.consoleRunning = true; + this.consoleOpen = true; + this.pollConsole(); + }); + } + /** Reopen it after it was closed; a finished release keeps its output to be read. */ showConsole() { this.consoleOpen = true; diff --git a/src/lib/mock.ts b/src/lib/mock.ts index c4bf3c2..81f9cfd 100644 --- a/src/lib/mock.ts +++ b/src/lib/mock.ts @@ -825,7 +825,8 @@ class MockBackend implements Backend { tile_sets: 8, tile_bytes: 102_650_000_000, tile_datasets: ["protomaps_20260423", "planet_heightmap"], - tiles_age_days: 161, + basemap_vintage: "20260423", + basemap_vintage_days: 161, }, }; } @@ -854,6 +855,25 @@ class MockBackend implements Backend { })(); } + async refreshRun(password: string, pgPassword: string, tileRoot: string): Promise { + await this.wait(300); + if (!password.trim()) throw "The node's sudo password is needed: the dump runs there."; + if (!pgPassword.trim() && !tileRoot.trim()) throw "Nothing to refresh: give the database password, a tile folder, or both."; + this.consoleLines = ["=== Database seed ==="]; + this.consoleDone = null; + const rest = pgPassword.trim() + ? ["live version : 0.75.0", "dumping odian from 192.168.1.13 ...", "snapshot : 20261001T090000Z (was 20260921T125618Z)", + "tables : 302 bytes: 15.40 GB", "Refresh finished. Nothing is published yet."] + : ["=== Tile indexes ===", "re-indexing 8 sets...", "Refresh finished. Nothing is published yet."]; + void (async () => { + for (const line of rest) { + await this.wait(600); + this.consoleLines.push(line); + } + this.consoleDone = 0; + })(); + } + private consoleLines: string[] = []; private consoleDone: number | null = 0; diff --git a/src/lib/types.ts b/src/lib/types.ts index bb32407..5eefe7f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -314,8 +314,10 @@ export interface ReleaseData { tile_sets: number; tile_bytes: number; tile_datasets: string[]; - /** Age of the oldest dated dataset; undated sets (heightmap) are not counted. */ - tiles_age_days: number | null; + /** The upstream extract the basemap was drawn from, `YYYYMMDD`. NOT a bake time: tiles + * record none anywhere, so there is no "how old are our tiles" to show. */ + basemap_vintage: string | null; + basemap_vintage_days: number | null; } export interface ReleasePlan { @@ -412,6 +414,8 @@ export interface Backend { releasePlan(): Promise; releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise; releaseConsole(since: number): Promise; + /** Rebuild the pack's data payload. Prepares inputs only; it publishes nothing. */ + refreshRun(password: string, pgPassword: string, tileRoot: string): Promise; /** What the operator has to decide, oldest first. */ getSyncConflicts(): Promise; /** Records one decision; resolves with what is left to decide. */