fix
This commit is contained in:
@@ -2427,11 +2427,24 @@ impl Launcher {
|
||||
// The commit the LIVE pack was cut from. Read from this PC's own copy of that version,
|
||||
// which costs nothing; when this PC is on an older version we simply do not know, and
|
||||
// the panel says so rather than guessing.
|
||||
let pack_commit = match (&installed, &pack_live) {
|
||||
(Some(have), Some(live)) if have == live => std::fs::read(Layout::new(&install_dir, have).version_dir().join("manifest.json"))
|
||||
// One read, two answers. The commit is only trusted when this PC is ON the live version
|
||||
// (an older copy says where IT came from, not where the live pack did), but the data
|
||||
// payload is worth showing either way - it is carried forward unchanged, so an older
|
||||
// manifest describes the same seed and the same tiles.
|
||||
let manifest = installed.as_ref().and_then(|have| {
|
||||
std::fs::read(Layout::new(&install_dir, have).version_dir().join("manifest.json"))
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_slice::<serde_json::Value>(&raw).ok())
|
||||
.and_then(|m| m.get("source_commit").and_then(|c| c.as_str().map(str::to_string))),
|
||||
});
|
||||
let is_live = matches!((&installed, &pack_live), (Some(have), Some(live)) if have == live);
|
||||
let pack_commit = match (is_live, &manifest) {
|
||||
(true, Some(m)) => m.get("source_commit").and_then(|c| c.as_str().map(str::to_string)),
|
||||
_ => None,
|
||||
};
|
||||
let data = match (&manifest, &installed) {
|
||||
(Some(m), Some(have)) => {
|
||||
Some(crate::release::data_from_manifest(m, have, is_live, now_secs() as i64))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// The launcher's own feeds are public, so this needs no token.
|
||||
@@ -2477,6 +2490,7 @@ impl Launcher {
|
||||
launcher_live,
|
||||
launcher_commit,
|
||||
None,
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+151
-1
@@ -23,6 +23,9 @@ pub struct ReleasePlan {
|
||||
pub items: Vec<ReleaseItem>,
|
||||
/// Why the whole thing cannot run, in one line; None when it can.
|
||||
pub blocked: Option<String>,
|
||||
/// What the pack carries but a release does not rebuild. None when nothing is installed
|
||||
/// here, so there is no manifest to read it from.
|
||||
pub data: Option<ReleaseData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -115,6 +118,107 @@ fn item(
|
||||
ReleaseItem { key, title, live, commit: commit.map(|c| c[..c.len().min(7)].to_string()), behind, dirty, note }
|
||||
}
|
||||
|
||||
/// What a pack carries that a code release does NOT rebuild.
|
||||
///
|
||||
/// `cut_pack.py` copies the database seed, the source chunks and the tile sets forward from
|
||||
/// the previous version every time, and asserts they did not change - which is what keeps a
|
||||
/// code release from quietly altering data. The cost is that they age invisibly: the seed and
|
||||
/// the tiles are whatever they were the day someone last built them. This puts a date on it.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ReleaseData {
|
||||
/// The version whose manifest these numbers were read from.
|
||||
pub version: String,
|
||||
/// Whether that is also what the DHP serves, so the numbers describe the live pack.
|
||||
pub is_live: bool,
|
||||
/// The seed's snapshot stamp, e.g. `20260921T125618Z`.
|
||||
pub db_snapshot: Option<String>,
|
||||
pub db_age_days: Option<i64>,
|
||||
pub db_bytes: u64,
|
||||
pub tile_sets: usize,
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Days from 1970-01-01 to y-m-d, proleptic Gregorian. Hinnant's algorithm, which is exact
|
||||
/// and needs no date crate - the alternative was a dependency to subtract two dates.
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = (m + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// How many days ago `YYYYMMDD` was, or None when those are not eight sane digits.
|
||||
fn age_days(stamp: &str, now_secs: i64) -> Option<i64> {
|
||||
let digits: String = stamp.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if digits.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let y: i64 = digits[0..4].parse().ok()?;
|
||||
let m: i64 = digits[4..6].parse().ok()?;
|
||||
let d: i64 = digits[6..8].parse().ok()?;
|
||||
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || !(2000..=2999).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
Some(now_secs / 86_400 - days_from_civil(y, m, d))
|
||||
}
|
||||
|
||||
/// The date a dataset name carries, if any: `protomaps_20260423` -> `20260423`.
|
||||
fn dataset_stamp(dataset: &str) -> Option<&str> {
|
||||
let tail = dataset.rsplit('_').next()?;
|
||||
(tail.len() == 8 && tail.chars().all(|c| c.is_ascii_digit())).then_some(tail)
|
||||
}
|
||||
|
||||
/// Read the data payload out of an installed version's manifest.
|
||||
pub fn data_from_manifest(m: &serde_json::Value, version: &str, is_live: bool, now_secs: i64) -> ReleaseData {
|
||||
let db = m.get("db");
|
||||
let db_snapshot = db.and_then(|d| d.get("snapshot")).and_then(|s| s.as_str()).map(str::to_string);
|
||||
let db_bytes = db
|
||||
.and_then(|d| d.get("files"))
|
||||
.and_then(|f| f.as_array())
|
||||
.map(|files| files.iter().filter_map(|f| f.get("size").and_then(serde_json::Value::as_u64)).sum())
|
||||
.unwrap_or(0);
|
||||
let sets = m.get("tiles").and_then(|t| t.get("sets")).and_then(|s| s.as_array());
|
||||
let tile_bytes = sets
|
||||
.map(|s| {
|
||||
s.iter()
|
||||
.filter_map(|set| set.get("bytes").or_else(|| set.get("size")).and_then(serde_json::Value::as_u64))
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let mut tile_datasets: Vec<String> = Vec::new();
|
||||
for set in sets.map(Vec::as_slice).unwrap_or(&[]) {
|
||||
if let Some(d) = set.get("dataset").and_then(|d| d.as_str()) {
|
||||
if !tile_datasets.iter().any(|have| have == d) {
|
||||
tile_datasets.push(d.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let tiles_age_days = tile_datasets
|
||||
.iter()
|
||||
.filter_map(|d| dataset_stamp(d))
|
||||
.filter_map(|s| age_days(s, now_secs))
|
||||
.max();
|
||||
ReleaseData {
|
||||
version: version.to_string(),
|
||||
is_live,
|
||||
db_age_days: db_snapshot.as_deref().and_then(|s| age_days(s, now_secs)),
|
||||
db_snapshot,
|
||||
db_bytes,
|
||||
tile_sets: sets.map(|s| s.len()).unwrap_or(0),
|
||||
tile_bytes,
|
||||
tile_datasets,
|
||||
tiles_age_days,
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the Release panel shows.
|
||||
pub fn plan(
|
||||
dev_root: &Path,
|
||||
@@ -125,6 +229,7 @@ pub fn plan(
|
||||
launcher_live: Option<String>,
|
||||
launcher_commit: Option<String>,
|
||||
api_live: Option<String>,
|
||||
data: Option<ReleaseData>,
|
||||
) -> ReleasePlan {
|
||||
let docker = docker_running();
|
||||
let scripts = mcp_repo.join("scripts").join("release_pack.ps1").is_file()
|
||||
@@ -157,7 +262,7 @@ pub fn plan(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
ReleasePlan { docker, online, scripts, items, blocked }
|
||||
ReleasePlan { docker, online, scripts, items, blocked, data }
|
||||
}
|
||||
|
||||
/// The Release console's buffer: filled by the running release, read by the window.
|
||||
@@ -446,6 +551,51 @@ pub fn next_launcher(live: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_pack_says_how_old_the_data_it_carries_is() {
|
||||
// 2026-10-01, the day this was written, as unix seconds.
|
||||
let now = days_from_civil(2026, 10, 1) * 86_400;
|
||||
assert_eq!(age_days("20261001T000000Z", now), Some(0), "today");
|
||||
assert_eq!(age_days("20260921T125618Z", now), Some(10), "the real 0.72.0 seed");
|
||||
assert_eq!(age_days("20260423", now), Some(161), "the real basemap extract");
|
||||
// Leap day, and a year boundary: the arithmetic is the whole point of this test.
|
||||
assert_eq!(age_days("20260228", now), Some(215));
|
||||
assert_eq!(age_days("20251231", now), Some(274));
|
||||
// Nonsense must not become a date.
|
||||
assert_eq!(age_days("", now), None);
|
||||
assert_eq!(age_days("2026", now), None);
|
||||
assert_eq!(age_days("20261301", now), None, "month 13");
|
||||
assert_eq!(age_days("latest", now), None);
|
||||
|
||||
assert_eq!(dataset_stamp("protomaps_20260423"), Some("20260423"));
|
||||
assert_eq!(dataset_stamp("planet_heightmap"), None, "undated: it does not move");
|
||||
|
||||
// A manifest shaped like the real one.
|
||||
let m: serde_json::Value = serde_json::json!({
|
||||
"db": { "snapshot": "20260921T125618Z",
|
||||
"files": [ {"size": 1000}, {"size": 2000} ] },
|
||||
"tiles": { "sets": [
|
||||
{"style": "dark", "dataset": "protomaps_20260423", "bytes": 10},
|
||||
{"style": "dark_v2", "dataset": "protomaps_20260423", "bytes": 20},
|
||||
{"style": "heightmap", "dataset": "planet_heightmap", "bytes": 70}
|
||||
]}
|
||||
});
|
||||
let d = data_from_manifest(&m, "0.72.0", true, now);
|
||||
assert_eq!(d.db_bytes, 3000);
|
||||
assert_eq!(d.db_age_days, Some(10));
|
||||
assert_eq!(d.tile_sets, 3);
|
||||
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));
|
||||
|
||||
// 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);
|
||||
assert_eq!(empty.tile_sets, 0);
|
||||
assert_eq!(empty.db_bytes, 0);
|
||||
assert_eq!(empty.db_age_days, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_version_moves_the_right_place() {
|
||||
// A pack is a minor (0.71.0 -> 0.72.0); the launcher is a patch (0.1.38 -> 0.1.39).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "ODIAN Launcher",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.43",
|
||||
"identifier": "fr.odian.launcher",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { prefersReducedMotion } from "svelte/motion";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { launcher } from "../lib/launcher.svelte";
|
||||
import { formatBytes } from "../lib/format";
|
||||
|
||||
// Its own panel rather than a block inside the action card: the card is anchored to the
|
||||
// bottom of the window and cannot grow, so three rows plus a password field pushed the
|
||||
@@ -24,6 +25,24 @@
|
||||
/** 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. */
|
||||
/** `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";
|
||||
const y = Number(snapshot.slice(0, 4));
|
||||
const m = Number(snapshot.slice(4, 6));
|
||||
const d = Number(snapshot.slice(6, 8));
|
||||
if (!y || !m || !d) return snapshot;
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
return `${d} ${MONTHS[m - 1] ?? "?"} ${y}`;
|
||||
}
|
||||
|
||||
function aged(days: number | null): string {
|
||||
if (days === null) return "";
|
||||
if (days <= 0) return "today";
|
||||
if (days === 1) return "1 day old";
|
||||
return `${days} days old`;
|
||||
}
|
||||
|
||||
function rowState(row: { behind: boolean | null; dirty: boolean }): { text: string; tone: string } {
|
||||
if (row.dirty) return { text: "uncommitted", tone: "warn" };
|
||||
if (row.behind === true) return { text: "behind", tone: "go" };
|
||||
@@ -62,6 +81,32 @@
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- The payload a release does NOT rebuild. It is copied forward from the previous
|
||||
version every time, so it ages quietly: this is the only place that says by how
|
||||
much, which is what turns "the tiles are old" into a decision. -->
|
||||
{#if plan.data}
|
||||
{@const d = plan.data}
|
||||
<div class="payload">
|
||||
<span class="payload-head">Carried forward — a release does not rebuild these</span>
|
||||
<div class="payload-row">
|
||||
<span class="payload-name">Database seed</span>
|
||||
<span class="payload-val mono">{stamped(d.db_snapshot)}</span>
|
||||
<span class="payload-age" class:stale={(d.db_age_days ?? 0) > 30}>{aged(d.db_age_days)}</span>
|
||||
<span class="payload-size mono">{formatBytes(d.db_bytes)}</span>
|
||||
</div>
|
||||
<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>
|
||||
<span class="payload-size mono">{formatBytes(d.tile_bytes)}</span>
|
||||
</div>
|
||||
<span class="payload-foot mono">{d.tile_datasets.join(" · ")}</span>
|
||||
{#if !d.is_live}
|
||||
<span class="payload-foot">Read from this PC's {d.version}, which is not what the DHP serves.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="checks mono">
|
||||
<span class:ok={plan.online}>DHP {plan.online ? "✓" : "✗"}</span>
|
||||
<span class:ok={plan.docker}>Docker {plan.docker ? "✓" : "✗"}</span>
|
||||
@@ -108,6 +153,48 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* The data payload. Quiet by default - it is context, not a control - but the age goes
|
||||
amber once it is old enough to matter, which is the whole point of showing it. */
|
||||
.payload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 10px 0 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.payload-head {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.payload-row {
|
||||
display: grid;
|
||||
grid-template-columns: 7.5rem 7rem minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.payload-name {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.payload-val,
|
||||
.payload-size {
|
||||
color: var(--muted);
|
||||
}
|
||||
.payload-age {
|
||||
color: var(--muted);
|
||||
}
|
||||
.payload-age.stale {
|
||||
color: var(--warn);
|
||||
}
|
||||
.payload-foot {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.release {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -815,6 +815,18 @@ class MockBackend implements Backend {
|
||||
{ key: "mcp", title: "MCP server", live: null, commit: "c7d8e9f", behind: null,
|
||||
dirty: false, note: "the script asks the node whether it is already running this commit" },
|
||||
],
|
||||
// The real numbers from pack 0.72.0, so the panel can be judged on what it will show.
|
||||
data: {
|
||||
version: this.status.installed ?? "0.72.0",
|
||||
is_live: true,
|
||||
db_snapshot: "20260921T125618Z",
|
||||
db_age_days: 10,
|
||||
db_bytes: 15_354_000_000,
|
||||
tile_sets: 8,
|
||||
tile_bytes: 102_650_000_000,
|
||||
tile_datasets: ["protomaps_20260423", "planet_heightmap"],
|
||||
tiles_age_days: 161,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -303,6 +303,21 @@ export interface ReleaseItem {
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** What a pack carries but a code release does not rebuild: the DB seed and the tile sets. */
|
||||
export interface ReleaseData {
|
||||
version: string;
|
||||
/** The numbers describe the live pack, not just this PC's older copy. */
|
||||
is_live: boolean;
|
||||
db_snapshot: string | null;
|
||||
db_age_days: number | null;
|
||||
db_bytes: number;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ReleasePlan {
|
||||
docker: boolean;
|
||||
online: boolean;
|
||||
@@ -310,6 +325,8 @@ export interface ReleasePlan {
|
||||
items: ReleaseItem[];
|
||||
/** Why a release cannot run at all; null when it can. */
|
||||
blocked: string | null;
|
||||
/** Null when nothing is installed here, so there is no manifest to read it from. */
|
||||
data: ReleaseData | null;
|
||||
}
|
||||
|
||||
/** A slice of the running release's output: what the window has not shown yet. */
|
||||
|
||||
Reference in New Issue
Block a user