This commit is contained in:
Kelorastus
2026-10-01 13:54:02 +02:00
parent c1a3bf03b8
commit 3a9de4013f
9 changed files with 273 additions and 17 deletions
+10
View File
@@ -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<crate::release::ConsoleTail> {
Ok(launcher.release_console(since))
+26
View File
@@ -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() {
+1
View File
@@ -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,
+99 -13
View File
@@ -138,9 +138,14 @@ pub struct ReleaseData {
pub tile_bytes: u64,
/// Every distinct dataset the tile sets name.
pub tile_datasets: Vec<String>,
/// 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<i64>,
/// 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<String>,
pub basemap_vintage_days: Option<i64>,
}
/// 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<PathBuf, String> {
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<PathBuf, String> {
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);
+95 -1
View File
@@ -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 @@
<div class="payload-row">
<span class="payload-name">Map tiles</span>
<span class="payload-val mono">{d.tile_sets} sets</span>
<span class="payload-age" class:stale={(d.tiles_age_days ?? 0) > 90}>{aged(d.tiles_age_days)}</span>
<!-- Deliberately NOT an age. Nothing records when tiles were baked, and the only
date available is the upstream extract's, which said "161 days old" about
work that is three weeks old. -->
<span class="payload-age" class:stale={(d.basemap_vintage_days ?? 0) > 365}>
{d.basemap_vintage ? `map data ${monthOf(d.basemap_vintage)}` : ""}
</span>
<span class="payload-size mono">{formatBytes(d.tile_bytes)}</span>
</div>
<span class="payload-foot mono">{d.tile_datasets.join(" · ")}</span>
<span class="payload-foot">
Tiles record no bake date, so only the source extract's vintage is known.
</span>
<div class="payload-act">
<button class="btn-link" onclick={() => (refreshOpen = !refreshOpen)}>
{refreshOpen ? "Never mind" : "Bring up to date…"}
</button>
</div>
{#if refreshOpen}
<div class="refresh">
<p class="refresh-why">
This rebuilds the seed from the live database and re-indexes the tiles. It
<strong>publishes nothing</strong> — it prepares the inputs, and the console says how to cut a
version from them.
</p>
<input
class="input"
type="password"
autocomplete="off"
placeholder="database password (Data PC) — leave empty to skip the seed"
bind:value={pgPassword}
disabled={busy}
/>
<input
class="input"
type="text"
autocomplete="off"
spellcheck="false"
placeholder="tile folder laid out as style\dataset — leave empty to skip the tiles"
bind:value={tileRoot}
disabled={busy}
/>
<div class="row">
<span class="refresh-note">
The seed is minutes of load on a database people are using. Uses the node password above.
</span>
<button class="btn" disabled={!canRefresh || busy} onclick={refresh}>Refresh</button>
</div>
</div>
{/if}
{#if !d.is_live}
<span class="payload-foot">Read from this PC's {d.version}, which is not what the DHP serves.</span>
{/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);
+1
View File
@@ -75,6 +75,7 @@ function createTauriBackend(): Backend {
releasePlan: () => invoke<ReleasePlan>("release_plan"),
releaseRun: (password, withApi, withMcp) => invoke<void>("release_run", { password, withApi, withMcp }),
releaseConsole: (since) => invoke<ReleaseConsoleTail>("release_console", { since }),
refreshRun: (password, pgPassword, tileRoot) => invoke<void>("refresh_run", { password, pgPassword, tileRoot }),
onTileSyncProgress: (handler) => subscribe<TileSyncProgress>("tile-sync-progress", handler),
onRemoveProgress: (handler) => subscribe<RemoveProgress>("remove-progress", handler),
onStack: (handler) => subscribe<StackEvent>("stack", handler),
+14
View File
@@ -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;
+21 -1
View File
@@ -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<void> {
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;
+6 -2
View File
@@ -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<ReleasePlan>;
releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise<void>;
releaseConsole(since: number): Promise<ReleaseConsoleTail>;
/** Rebuild the pack's data payload. Prepares inputs only; it publishes nothing. */
refreshRun(password: string, pgPassword: string, tileRoot: string): Promise<void>;
/** What the operator has to decide, oldest first. */
getSyncConflicts(): Promise<SyncConflict[]>;
/** Records one decision; resolves with what is left to decide. */