Compare commits
19 Commits
a1cc4e086d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f69ba7c016 | |||
| 540f694f01 | |||
| ee2d56b705 | |||
| 53bba10b3e | |||
| 7a97afe046 | |||
| 9d4da39cb6 | |||
| 4450722428 | |||
| 167dbd6d3a | |||
| 30123e5576 | |||
| 6af563793b | |||
| dbe7d43387 | |||
| 9a3094bebc | |||
| 7843d48c8f | |||
| 3a9de4013f | |||
| c1a3bf03b8 | |||
| 064c1b9098 | |||
| 1c25891ab7 | |||
| e28101ada5 | |||
| 52f77c814c |
@@ -28,8 +28,19 @@ pub async fn release_run(
|
|||||||
password: String,
|
password: String,
|
||||||
with_api: bool,
|
with_api: bool,
|
||||||
with_mcp: bool,
|
with_mcp: bool,
|
||||||
|
with_web: bool,
|
||||||
) -> CmdResult<()> {
|
) -> CmdResult<()> {
|
||||||
launcher.release_run(password, with_api, with_mcp).await
|
launcher.release_run(password, with_api, with_mcp, with_web).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]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -2427,11 +2427,24 @@ impl Launcher {
|
|||||||
// The commit the LIVE pack was cut from. Read from this PC's own copy of that version,
|
// 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
|
// which costs nothing; when this PC is on an older version we simply do not know, and
|
||||||
// the panel says so rather than guessing.
|
// the panel says so rather than guessing.
|
||||||
let pack_commit = match (&installed, &pack_live) {
|
// One read, two answers. The commit is only trusted when this PC is ON the live version
|
||||||
(Some(have), Some(live)) if have == live => std::fs::read(Layout::new(&install_dir, have).version_dir().join("manifest.json"))
|
// (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()
|
.ok()
|
||||||
.and_then(|raw| serde_json::from_slice::<serde_json::Value>(&raw).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,
|
_ => None,
|
||||||
};
|
};
|
||||||
// The launcher's own feeds are public, so this needs no token.
|
// The launcher's own feeds are public, so this needs no token.
|
||||||
@@ -2477,12 +2490,13 @@ impl Launcher {
|
|||||||
launcher_live,
|
launcher_live,
|
||||||
launcher_commit,
|
launcher_commit,
|
||||||
None,
|
None,
|
||||||
|
data,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start a release. Returns once it has started, not when it is done: its output arrives
|
/// Start a release. Returns once it has started, not when it is done: its output arrives
|
||||||
/// in the console panel, which reads `release_console` as it fills.
|
/// in the console panel, which reads `release_console` as it fills.
|
||||||
pub async fn release_run(&self, password: String, with_api: bool, with_mcp: bool) -> Result<(), String> {
|
pub async fn release_run(&self, password: String, with_api: bool, with_mcp: bool, with_web: bool) -> Result<(), String> {
|
||||||
// Two at once would interleave into one buffer and fight over the same build dirs.
|
// Two at once would interleave into one buffer and fight over the same build dirs.
|
||||||
if self.release_console.lock().is_ok_and(|c| c.is_running()) {
|
if self.release_console.lock().is_ok_and(|c| c.is_running()) {
|
||||||
return Err("A release is already running: wait for it to finish.".to_string());
|
return Err("A release is already running: wait for it to finish.".to_string());
|
||||||
@@ -2519,6 +2533,33 @@ impl Launcher {
|
|||||||
launcher_next.as_deref(),
|
launcher_next.as_deref(),
|
||||||
with_api && plan.docker,
|
with_api && plan.docker,
|
||||||
with_mcp && plan.docker,
|
with_mcp && plan.docker,
|
||||||
|
with_web && plan.docker,
|
||||||
|
&password,
|
||||||
|
&self.release_console,
|
||||||
|
)
|
||||||
|
.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,
|
&password,
|
||||||
&self.release_console,
|
&self.release_console,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ pub fn run() {
|
|||||||
commands::release_plan,
|
commands::release_plan,
|
||||||
commands::release_run,
|
commands::release_run,
|
||||||
commands::release_console,
|
commands::release_console,
|
||||||
|
commands::refresh_run,
|
||||||
commands::get_market,
|
commands::get_market,
|
||||||
commands::apply_market,
|
commands::apply_market,
|
||||||
commands::check_market,
|
commands::check_market,
|
||||||
|
|||||||
+267
-9
@@ -23,6 +23,9 @@ pub struct ReleasePlan {
|
|||||||
pub items: Vec<ReleaseItem>,
|
pub items: Vec<ReleaseItem>,
|
||||||
/// Why the whole thing cannot run, in one line; None when it can.
|
/// Why the whole thing cannot run, in one line; None when it can.
|
||||||
pub blocked: Option<String>,
|
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)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -115,6 +118,115 @@ fn item(
|
|||||||
ReleaseItem { key, title, live, commit: commit.map(|c| c[..c.len().min(7)].to_string()), behind, dirty, note }
|
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 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
|
||||||
|
/// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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).map(|age| (age, s.to_string())))
|
||||||
|
.max_by_key(|(age, _)| *age)
|
||||||
|
.map(|(_, s)| s);
|
||||||
|
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,
|
||||||
|
basemap_vintage_days: basemap_vintage.as_deref().and_then(|s| age_days(s, now_secs)),
|
||||||
|
basemap_vintage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything the Release panel shows.
|
/// Everything the Release panel shows.
|
||||||
pub fn plan(
|
pub fn plan(
|
||||||
dev_root: &Path,
|
dev_root: &Path,
|
||||||
@@ -125,6 +237,7 @@ pub fn plan(
|
|||||||
launcher_live: Option<String>,
|
launcher_live: Option<String>,
|
||||||
launcher_commit: Option<String>,
|
launcher_commit: Option<String>,
|
||||||
api_live: Option<String>,
|
api_live: Option<String>,
|
||||||
|
data: Option<ReleaseData>,
|
||||||
) -> ReleasePlan {
|
) -> ReleasePlan {
|
||||||
let docker = docker_running();
|
let docker = docker_running();
|
||||||
let scripts = mcp_repo.join("scripts").join("release_pack.ps1").is_file()
|
let scripts = mcp_repo.join("scripts").join("release_pack.ps1").is_file()
|
||||||
@@ -157,7 +270,7 @@ pub fn plan(
|
|||||||
} else {
|
} else {
|
||||||
None
|
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.
|
/// The Release console's buffer: filled by the running release, read by the window.
|
||||||
@@ -271,6 +384,7 @@ pub fn run_release(
|
|||||||
launcher_version: Option<&str>,
|
launcher_version: Option<&str>,
|
||||||
with_api: bool,
|
with_api: bool,
|
||||||
with_mcp: bool,
|
with_mcp: bool,
|
||||||
|
with_web: bool,
|
||||||
sudo: &str,
|
sudo: &str,
|
||||||
console: &Shared,
|
console: &Shared,
|
||||||
) -> Result<PathBuf, String> {
|
) -> Result<PathBuf, String> {
|
||||||
@@ -298,14 +412,35 @@ pub fn run_release(
|
|||||||
// It ships with the release skill rather than the repo, so look there when the repo
|
// It ships with the release skill rather than the repo, so look there when the repo
|
||||||
// has no copy. A missing script used to fail the same way a broken one does.
|
// has no copy. A missing script used to fail the same way a broken one does.
|
||||||
let api = api_script(&scripts).ok_or(
|
let api = api_script(&scripts).ok_or(
|
||||||
"deploy-api.ps1 is neither in the repo's scripts nor in the release skill: the API cannot be rolled.",
|
format!(
|
||||||
|
"deploy-api.ps1 is not in {}. Pull ODIAN_MCP_Server on this PC, or untick the Engine API.",
|
||||||
|
scripts.display()
|
||||||
|
),
|
||||||
)?;
|
)?;
|
||||||
body.push_str(&format!(
|
body.push_str(&format!(
|
||||||
"Write-Host '=== Engine API ===' -ForegroundColor Cyan
|
"Write-Host '=== Engine API ===' -ForegroundColor Cyan
|
||||||
& '{}' -NodeSudoPw $pw
|
& '{}' -NodeSudoPw $pw -EngineRoot '{}'
|
||||||
if ($LASTEXITCODE -notin 0,3) {{ throw 'the API step failed' }}
|
if ($LASTEXITCODE -notin 0,3) {{ throw 'the API step failed' }}
|
||||||
",
|
",
|
||||||
api.display()
|
api.display(),
|
||||||
|
// The checkout is the sibling of ODIAN_MCP_Server, which is what `mcp_repo` names.
|
||||||
|
// Passed explicitly so the script does not have to guess from where it was loaded -
|
||||||
|
// it can come from the repo or from the release skill, and only one of those is a
|
||||||
|
// sibling of the Engine.
|
||||||
|
mcp_repo.parent().unwrap_or(mcp_repo).join("ODIAN_Engine").display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if with_web {
|
||||||
|
// From _WEB in the Engine checkout. Its Dockerfile runs npm inside the image, so this
|
||||||
|
// needs Docker and nothing else - no Node, no node_modules on this PC.
|
||||||
|
body.push_str(&format!(
|
||||||
|
"Write-Host '=== ODIAN WEB ===' -ForegroundColor Cyan
|
||||||
|
& '{}'
|
||||||
|
if ($LASTEXITCODE -eq 2) {{ Write-Host 'web: already serving this commit' -ForegroundColor Yellow }}
|
||||||
|
elseif ($LASTEXITCODE -ne 0) {{ throw 'the web step failed' }}
|
||||||
|
",
|
||||||
|
scripts.join("deploy_web.ps1").display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,18 +463,78 @@ pub fn run_release(
|
|||||||
// No `pause` here any more: this runs into the launcher's own panel, where there is no
|
// 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
|
// 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.
|
// 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'
|
"$ErrorActionPreference = 'Stop'
|
||||||
# The pipe gets UTF-8 or Windows' own French messages arrive as mojibake.
|
# The pipe gets UTF-8 or Windows' own French messages arrive as mojibake.
|
||||||
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
|
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
|
||||||
$pw = $env:ODIAN_NODE_SUDO
|
$pw = $env:ODIAN_NODE_SUDO
|
||||||
$env:ODIAN_NODE_SUDO = $null
|
$env:ODIAN_NODE_SUDO = $null
|
||||||
|
$pgpw = $env:ODIAN_PG_PW
|
||||||
|
$env:ODIAN_PG_PW = $null
|
||||||
Start-Transcript -Path '{}' -Force | Out-Null
|
Start-Transcript -Path '{}' -Force | Out-Null
|
||||||
$ok = $true
|
$ok = $true
|
||||||
try {{
|
try {{
|
||||||
{}
|
{}
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host 'Release finished.'
|
Write-Host '{}'
|
||||||
}} catch {{
|
}} catch {{
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host \"FAILED: $_\"
|
Write-Host \"FAILED: $_\"
|
||||||
@@ -353,16 +548,31 @@ if (-not $ok) {{ exit 1 }}
|
|||||||
",
|
",
|
||||||
log.display(),
|
log.display(),
|
||||||
body,
|
body,
|
||||||
|
done,
|
||||||
log.display()
|
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()))?;
|
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()))?;
|
std::fs::write(&file, ps).map_err(|e| format!("cannot write {}: {e}", file.display()))?;
|
||||||
|
|
||||||
let mut cmd = Command::new("powershell");
|
let mut cmd = Command::new("powershell");
|
||||||
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]).arg(&file);
|
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
|
// 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.
|
// then fails at once instead of hanging forever with no window to answer in.
|
||||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
@@ -438,6 +648,54 @@ pub fn next_launcher(live: &str) -> Option<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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()]);
|
||||||
|
// 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);
|
||||||
|
assert_eq!(empty.tile_sets, 0);
|
||||||
|
assert_eq!(empty.db_bytes, 0);
|
||||||
|
assert_eq!(empty.db_age_days, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_next_version_moves_the_right_place() {
|
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).
|
// 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",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "ODIAN Launcher",
|
"productName": "ODIAN Launcher",
|
||||||
"version": "0.1.40",
|
"version": "0.1.51",
|
||||||
"identifier": "fr.odian.launcher",
|
"identifier": "fr.odian.launcher",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -98,6 +98,12 @@
|
|||||||
// like the real one on screen, and mistaking the two is how a bug gets chased in the
|
// like the real one on screen, and mistaking the two is how a bug gets chased in the
|
||||||
// wrong tree - or a demo gets given from an unbuilt branch.
|
// wrong tree - or a demo gets given from an unbuilt branch.
|
||||||
const devOn = $derived(launcher.devOn);
|
const devOn = $derived(launcher.devOn);
|
||||||
|
// Dev mode's controls follow DEV MODE, not whatever the hub is currently offering. They
|
||||||
|
// used to live inside the "launch" branch, so a PC with nothing installed was told to name
|
||||||
|
// its checkout folder and given nowhere on screen to name it - the dead end that made a
|
||||||
|
// fresh dev PC look like it could only install.
|
||||||
|
const TRANSIENT = ["loading", "signing_in", "installing", "restoring", "syncing", "building", "removing", "pulling_tiles", "stack"];
|
||||||
|
const showDev = $derived(devOn && !TRANSIENT.includes(action));
|
||||||
const devRun = $derived(launcher.devReady);
|
const devRun = $derived(launcher.devReady);
|
||||||
const devRoot = $derived(launcher.settings?.dev_root ?? "");
|
const devRoot = $derived(launcher.settings?.dev_root ?? "");
|
||||||
const devTarget = $derived(launcher.settings?.dev_target ?? "client");
|
const devTarget = $derived(launcher.settings?.dev_target ?? "client");
|
||||||
@@ -251,6 +257,20 @@
|
|||||||
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
|
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
{#if s?.online && !s.signed_in && !checking}
|
||||||
|
<!-- The operator is remembered with the install (launcher.json travels with a drive),
|
||||||
|
the DHP sign-in is not (Credential Manager, per PC). So a drive on another PC offers
|
||||||
|
Launch as if nothing were missing, and ODIAN then runs on that PC's own data: no live
|
||||||
|
aircraft, no tiles from the site. Launch stays the button - a sign-in that cannot
|
||||||
|
complete must not stop ODIAN - but the gap is said here, with the way out. -->
|
||||||
|
<p class="secondary attention">
|
||||||
|
<Icon name="alert" size={14} />
|
||||||
|
<span>
|
||||||
|
Not signed in on this PC: ODIAN will run on this PC's own data, without live aircraft.
|
||||||
|
<button class="btn-link" disabled={blocked} onclick={() => launcher.signIn()}>Sign in</button>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{#if launcher.syncAttention > 0}
|
{#if launcher.syncAttention > 0}
|
||||||
<p class="secondary attention">
|
<p class="secondary attention">
|
||||||
<Icon name="alert" size={14} />
|
<Icon name="alert" size={14} />
|
||||||
@@ -266,98 +286,6 @@
|
|||||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
|
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if devOn}
|
|
||||||
<!-- Dev mode's own controls, where the launching happens. Everything that decides
|
|
||||||
what Launch does is here rather than behind Settings: the target, the checkout,
|
|
||||||
and whether it builds first. -->
|
|
||||||
<section class="dev" aria-label="Dev mode">
|
|
||||||
<header class="dev-head">
|
|
||||||
<Icon name="code" size={13} />
|
|
||||||
<span class="dev-title">DEV</span>
|
|
||||||
<!-- What Launch will actually start, in three words. The folder is not repeated
|
|
||||||
here: it is in the field two rows down. -->
|
|
||||||
<span class="dev-from mono">{devRun ? TARGETS.find((o) => o.key === devTarget)?.name ?? "" : "no checkout"}</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="dev-targets" role="radiogroup" aria-label="Launch starts">
|
|
||||||
{#each TARGETS as option (option.key)}
|
|
||||||
<label class="dev-target" class:on={devTarget === option.key}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="dev-target"
|
|
||||||
value={option.key}
|
|
||||||
checked={devTarget === option.key}
|
|
||||||
disabled={blocked || savingDev}
|
|
||||||
onchange={() => launcher.setDev({ dev_target: option.key })}
|
|
||||||
/>
|
|
||||||
<span class="dev-target-name">{option.name}</span>
|
|
||||||
<span class="dev-target-note mono">{option.note}</span>
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dev-row">
|
|
||||||
<input
|
|
||||||
class="dev-path"
|
|
||||||
type="text"
|
|
||||||
spellcheck="false"
|
|
||||||
placeholder="Folder holding ODIAN_Engine"
|
|
||||||
value={devRoot}
|
|
||||||
disabled={blocked || savingDev}
|
|
||||||
onchange={(e) => launcher.setDev({ dev_root: e.currentTarget.value.trim() })}
|
|
||||||
/>
|
|
||||||
<button type="button" class="btn" disabled={blocked || savingDev} onclick={browseCheckout}>Browse…</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dev-row">
|
|
||||||
<label class="dev-label" for="dev-run-as">Run as</label>
|
|
||||||
<input
|
|
||||||
id="dev-run-as"
|
|
||||||
class="dev-path"
|
|
||||||
type="text"
|
|
||||||
spellcheck="false"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder={s?.operator ?? "the operator who signed in"}
|
|
||||||
value={devUserField}
|
|
||||||
disabled={blocked || savingDev}
|
|
||||||
onchange={(e) => launcher.setDev({ dev_user: e.currentTarget.value.trim() })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dev-row">
|
|
||||||
{#if releasing}
|
|
||||||
<button class="btn dev-release" onclick={() => launcher.showConsole()}>Release running…</button>
|
|
||||||
{:else}
|
|
||||||
<button class="btn dev-release" disabled={blocked || !s?.online} onclick={openRelease}>
|
|
||||||
Build & deploy…
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if hasOutput && !releasing}
|
|
||||||
<button class="btn-link" onclick={() => launcher.showConsole()}>last output</button>
|
|
||||||
{:else if !s?.online}
|
|
||||||
<span class="dev-note">needs the DHP</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="dev-check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={devBuild}
|
|
||||||
disabled={blocked || savingDev}
|
|
||||||
onchange={(e) => launcher.setDev({ dev_build: e.currentTarget.checked })}
|
|
||||||
/>
|
|
||||||
<span>Build first</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{#if !devRun}
|
|
||||||
<p class="dev-note">Name the folder that holds <code>ODIAN_Engine</code> and Launch runs from it.</p>
|
|
||||||
{:else if devUser}
|
|
||||||
<p class="dev-note attention">
|
|
||||||
Running as <strong>{devUser}</strong> — this PC will not sync with the DHP.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if unfinished}
|
{#if unfinished}
|
||||||
<p class="secondary attention">
|
<p class="secondary attention">
|
||||||
@@ -492,6 +420,102 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showDev}
|
||||||
|
<!-- Dev mode's own controls, where the launching happens. Everything that decides
|
||||||
|
what Launch does is here rather than behind Settings: the target, the checkout,
|
||||||
|
and whether it builds first. -->
|
||||||
|
<section class="dev" aria-label="Dev mode">
|
||||||
|
<header class="dev-head">
|
||||||
|
<Icon name="code" size={13} />
|
||||||
|
<span class="dev-title">DEV</span>
|
||||||
|
<!-- What Launch will actually start, in three words. The folder is not repeated
|
||||||
|
here: it is in the field two rows down. -->
|
||||||
|
<span class="dev-from mono">{devRun ? TARGETS.find((o) => o.key === devTarget)?.name ?? "" : "no checkout"}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dev-targets" role="radiogroup" aria-label="Launch starts">
|
||||||
|
{#each TARGETS as option (option.key)}
|
||||||
|
<label class="dev-target" class:on={devTarget === option.key}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="dev-target"
|
||||||
|
value={option.key}
|
||||||
|
checked={devTarget === option.key}
|
||||||
|
disabled={blocked || savingDev}
|
||||||
|
onchange={() => launcher.setDev({ dev_target: option.key })}
|
||||||
|
/>
|
||||||
|
<span class="dev-target-name">{option.name}</span>
|
||||||
|
<span class="dev-target-note mono">{option.note}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dev-row">
|
||||||
|
<input
|
||||||
|
class="dev-path"
|
||||||
|
type="text"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="Folder holding ODIAN_Engine"
|
||||||
|
value={devRoot}
|
||||||
|
disabled={blocked || savingDev}
|
||||||
|
onchange={(e) => launcher.setDev({ dev_root: e.currentTarget.value.trim() })}
|
||||||
|
/>
|
||||||
|
<button type="button" class="btn" disabled={blocked || savingDev} onclick={browseCheckout}>Browse…</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dev-row">
|
||||||
|
<label class="dev-label" for="dev-run-as">Run as</label>
|
||||||
|
<input
|
||||||
|
id="dev-run-as"
|
||||||
|
class="dev-path"
|
||||||
|
type="text"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder={s?.operator ?? "the operator who signed in"}
|
||||||
|
value={devUserField}
|
||||||
|
disabled={blocked || savingDev}
|
||||||
|
onchange={(e) => launcher.setDev({ dev_user: e.currentTarget.value.trim() })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dev-row">
|
||||||
|
{#if releasing}
|
||||||
|
<button class="btn dev-release" onclick={() => launcher.showConsole()}>Release running…</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn dev-release" disabled={blocked || !s?.online} onclick={openRelease}>
|
||||||
|
Build & deploy…
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if hasOutput && !releasing}
|
||||||
|
<button class="btn-link" onclick={() => launcher.showConsole()}>last output</button>
|
||||||
|
{:else if !s?.online}
|
||||||
|
<span class="dev-note">needs the DHP</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="dev-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={devBuild}
|
||||||
|
disabled={blocked || savingDev}
|
||||||
|
onchange={(e) => launcher.setDev({ dev_build: e.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span>Build first</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if !devRun}
|
||||||
|
<p class="dev-note">Name the folder that holds <code>ODIAN_Engine</code> and Launch runs from it.</p>
|
||||||
|
{:else if devUser}
|
||||||
|
<!-- One line, and ONE flex child: `.attention` is display:flex, so loose text and
|
||||||
|
<strong> each become a column. "no live data" earns its place - the old wording
|
||||||
|
said only "no sync", and the missing tracks were hunted through the whole stack. -->
|
||||||
|
<p class="dev-note attention">
|
||||||
|
<span>Running as <strong>{devUser}</strong> — no sync, no live data.</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showNotice}
|
{#if showNotice}
|
||||||
<div class="notice" role="alert">
|
<div class="notice" role="alert">
|
||||||
<Icon name="alert" size={14} />
|
<Icon name="alert" size={14} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { prefersReducedMotion } from "svelte/motion";
|
import { prefersReducedMotion } from "svelte/motion";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
import { launcher } from "../lib/launcher.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
|
// 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
|
// bottom of the window and cannot grow, so three rows plus a password field pushed the
|
||||||
@@ -15,15 +16,57 @@
|
|||||||
// Off by default, deliberately: it serves /download/* and proxies /dhp, so it is the one
|
// Off by default, deliberately: it serves /download/* and proxies /dhp, so it is the one
|
||||||
// step that interrupts the road everything else publishes over.
|
// step that interrupts the road everything else publishes over.
|
||||||
let withMcp = $state(false);
|
let withMcp = $state(false);
|
||||||
|
// The browser client at web.odian.fr. Built from _WEB in the Engine checkout, in Docker.
|
||||||
|
let withWeb = $state(false);
|
||||||
|
|
||||||
async function release() {
|
async function release() {
|
||||||
await launcher.releaseRun(password, withApi, withMcp);
|
await launcher.releaseRun(password, withApi, withMcp, withWeb);
|
||||||
password = ""; // held for the one run, never kept
|
password = ""; // held for the one run, never kept
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The short version of a row's state, for the column that has room for three words.
|
/** 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
|
NOT called `state`: that shadows the `$state` rune in this file, and everything
|
||||||
declared with it silently stops being reactive. */
|
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";
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `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";
|
||||||
|
if (days === 1) return "1 day old";
|
||||||
|
return `${days} days old`;
|
||||||
|
}
|
||||||
|
|
||||||
function rowState(row: { behind: boolean | null; dirty: boolean }): { text: string; tone: string } {
|
function rowState(row: { behind: boolean | null; dirty: boolean }): { text: string; tone: string } {
|
||||||
if (row.dirty) return { text: "uncommitted", tone: "warn" };
|
if (row.dirty) return { text: "uncommitted", tone: "warn" };
|
||||||
if (row.behind === true) return { text: "behind", tone: "go" };
|
if (row.behind === true) return { text: "behind", tone: "go" };
|
||||||
@@ -62,6 +105,77 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</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>
|
||||||
|
<!-- 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}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="checks mono">
|
<div class="checks mono">
|
||||||
<span class:ok={plan.online}>DHP {plan.online ? "✓" : "✗"}</span>
|
<span class:ok={plan.online}>DHP {plan.online ? "✓" : "✗"}</span>
|
||||||
<span class:ok={plan.docker}>Docker {plan.docker ? "✓" : "✗"}</span>
|
<span class:ok={plan.docker}>Docker {plan.docker ? "✓" : "✗"}</span>
|
||||||
@@ -82,6 +196,13 @@
|
|||||||
<em>— every operator is logged out</em>
|
<em>— every operator is logged out</em>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" bind:checked={withWeb} disabled={!plan.docker || busy} />
|
||||||
|
<span>
|
||||||
|
Also deploy ODIAN WEB{plan.docker ? "" : " — needs Docker"}
|
||||||
|
<em>— web.odian.fr, from the Engine checkout</em>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label class="check">
|
<label class="check">
|
||||||
<input type="checkbox" bind:checked={withMcp} disabled={!plan.docker || busy} />
|
<input type="checkbox" bind:checked={withMcp} disabled={!plan.docker || busy} />
|
||||||
<span>
|
<span>
|
||||||
@@ -108,6 +229,75 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<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-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);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.release {
|
.release {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+3
-1
@@ -73,8 +73,10 @@ function createTauriBackend(): Backend {
|
|||||||
onStatus: (handler) => subscribe<Status>("status", handler),
|
onStatus: (handler) => subscribe<Status>("status", handler),
|
||||||
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
|
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
|
||||||
releasePlan: () => invoke<ReleasePlan>("release_plan"),
|
releasePlan: () => invoke<ReleasePlan>("release_plan"),
|
||||||
releaseRun: (password, withApi, withMcp) => invoke<void>("release_run", { password, withApi, withMcp }),
|
releaseRun: (password, withApi, withMcp, withWeb) =>
|
||||||
|
invoke<void>("release_run", { password, withApi, withMcp, withWeb }),
|
||||||
releaseConsole: (since) => invoke<ReleaseConsoleTail>("release_console", { since }),
|
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),
|
onTileSyncProgress: (handler) => subscribe<TileSyncProgress>("tile-sync-progress", handler),
|
||||||
onRemoveProgress: (handler) => subscribe<RemoveProgress>("remove-progress", handler),
|
onRemoveProgress: (handler) => subscribe<RemoveProgress>("remove-progress", handler),
|
||||||
onStack: (handler) => subscribe<StackEvent>("stack", handler),
|
onStack: (handler) => subscribe<StackEvent>("stack", handler),
|
||||||
|
|||||||
@@ -420,9 +420,9 @@ class Launcher {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseRun(password: string, withApi: boolean, withMcp: boolean) {
|
releaseRun(password: string, withApi: boolean, withMcp: boolean, withWeb: boolean) {
|
||||||
return this.run("release", async () => {
|
return this.run("release", async () => {
|
||||||
await backend.releaseRun(password, withApi, withMcp);
|
await backend.releaseRun(password, withApi, withMcp, withWeb);
|
||||||
this.releaseOpen = false;
|
this.releaseOpen = false;
|
||||||
// Straight into the console: the release is running with nowhere else to show itself.
|
// Straight into the console: the release is running with nowhere else to show itself.
|
||||||
this.consoleLines = [];
|
this.consoleLines = [];
|
||||||
@@ -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. */
|
/** Reopen it after it was closed; a finished release keeps its output to be read. */
|
||||||
showConsole() {
|
showConsole() {
|
||||||
this.consoleOpen = true;
|
this.consoleOpen = true;
|
||||||
|
|||||||
+56
-3
@@ -1,7 +1,7 @@
|
|||||||
// Browser-only stand-in for the Rust backend, so the hub can be designed with
|
// Browser-only stand-in for the Rust backend, so the hub can be designed with
|
||||||
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
|
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
|
||||||
//
|
//
|
||||||
// ?scenario=first-run | update-required | ready | offline-installed |
|
// ?scenario=first-run | update-required | ready | new-pc | offline-installed |
|
||||||
// offline-not-installed | installing | running | sync-conflicts | error |
|
// offline-not-installed | installing | running | sync-conflicts | error |
|
||||||
// dev (a licensed developer PC: the Dev section of Settings appears)
|
// dev (a licensed developer PC: the Dev section of Settings appears)
|
||||||
// &fail=sign_in | install | restore | launch | notes make that flow fail
|
// &fail=sign_in | install | restore | launch | notes make that flow fail
|
||||||
@@ -265,6 +265,11 @@ function initialStatus(scenario: MockScenario): Status {
|
|||||||
case "ready":
|
case "ready":
|
||||||
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true });
|
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true });
|
||||||
break;
|
break;
|
||||||
|
case "new-pc":
|
||||||
|
// A drive on a PC it has never been on: the install and its operator came with it,
|
||||||
|
// the DHP sign-in (per PC) did not. Online, launchable, and not signed in.
|
||||||
|
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: false });
|
||||||
|
break;
|
||||||
case "offline-installed":
|
case "offline-installed":
|
||||||
Object.assign(s, { online: false, installed: PREVIOUS, operator: OPERATOR });
|
Object.assign(s, { online: false, installed: PREVIOUS, operator: OPERATOR });
|
||||||
break;
|
break;
|
||||||
@@ -311,6 +316,19 @@ function initialStatus(scenario: MockScenario): Status {
|
|||||||
dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" },
|
dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" },
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "dev-no-checkout":
|
||||||
|
// A fresh dev PC: launcher downloaded, licence dropped in, DHP reachable - but no
|
||||||
|
// ODIAN_Engine checkout yet. It used to show Install and no way to point at one.
|
||||||
|
Object.assign(s, {
|
||||||
|
installed: null,
|
||||||
|
operator: OPERATOR,
|
||||||
|
signed_in: true,
|
||||||
|
dev_run: false,
|
||||||
|
dhp_direct: false,
|
||||||
|
dhp_blocked: "dev mode has no checkout folder yet: name the folder that holds ODIAN_Engine.",
|
||||||
|
dev: { machine: "ODIAN-DEV-02", install_id: null, expires_at: "2027-01-31", note: "test kit" },
|
||||||
|
});
|
||||||
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
Object.assign(s, {
|
Object.assign(s, {
|
||||||
operator: OPERATOR,
|
operator: OPERATOR,
|
||||||
@@ -370,6 +388,7 @@ class MockBackend implements Backend {
|
|||||||
// Already pointed at a checkout and switched on, so the dev controls and the dev-run
|
// Already pointed at a checkout and switched on, so the dev controls and the dev-run
|
||||||
// marker show as they do on the real thing.
|
// marker show as they do on the real thing.
|
||||||
if (scenario === "dev" || scenario === "dev-dhp") Object.assign(this.settings, { dev_root: "E:" + String.fromCharCode(92) + "ODIAN_Dev", dev_mode: true });
|
if (scenario === "dev" || scenario === "dev-dhp") Object.assign(this.settings, { dev_root: "E:" + String.fromCharCode(92) + "ODIAN_Dev", dev_mode: true });
|
||||||
|
if (scenario === "dev-no-checkout") Object.assign(this.settings, { dev_root: "", dev_mode: true });
|
||||||
if (this.status.installed) {
|
if (this.status.installed) {
|
||||||
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
||||||
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
||||||
@@ -800,11 +819,26 @@ class MockBackend implements Backend {
|
|||||||
dirty: false, note: "what is published does not say which commit it came from" },
|
dirty: false, note: "what is published does not say which commit it came from" },
|
||||||
{ key: "mcp", title: "MCP server", live: null, commit: "c7d8e9f", behind: null,
|
{ 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" },
|
dirty: false, note: "the script asks the node whether it is already running this commit" },
|
||||||
|
{ key: "web", title: "ODIAN WEB", live: "0.1.7", commit: "a1b2c3d", behind: null,
|
||||||
|
dirty: false, note: "web.odian.fr; the script asks whether it already serves 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"],
|
||||||
|
basemap_vintage: "20260423",
|
||||||
|
basemap_vintage_days: 161,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise<void> {
|
async releaseRun(password: string, withApi: boolean, withMcp: boolean, withWeb: boolean): Promise<void> {
|
||||||
await this.wait(400);
|
await this.wait(400);
|
||||||
if (!password.trim()) throw "The node's sudo password is needed to cut a pack and to roll the API.";
|
if (!password.trim()) throw "The node's sudo password is needed to cut a pack and to roll the API.";
|
||||||
// Nothing runs in the browser, so play a short script into the console instead: enough
|
// Nothing runs in the browser, so play a short script into the console instead: enough
|
||||||
@@ -815,7 +849,7 @@ class MockBackend implements Backend {
|
|||||||
"engine at 1255873...",
|
"engine at 1255873...",
|
||||||
"Nothing that ships has changed. No pack cut.",
|
"Nothing that ships has changed. No pack cut.",
|
||||||
"pack: nothing to release",
|
"pack: nothing to release",
|
||||||
`=== Launcher 0.1.40 === (API ${withApi}, MCP ${withMcp})`,
|
`=== Launcher 0.1.40 === (API ${withApi}, MCP ${withMcp}, web ${withWeb})`,
|
||||||
"npx tauri build ...",
|
"npx tauri build ...",
|
||||||
"Release finished.",
|
"Release finished.",
|
||||||
];
|
];
|
||||||
@@ -828,6 +862,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 consoleLines: string[] = [];
|
||||||
private consoleDone: number | null = 0;
|
private consoleDone: number | null = 0;
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -303,6 +303,23 @@ export interface ReleaseItem {
|
|||||||
note: string;
|
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[];
|
||||||
|
/** 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 {
|
export interface ReleasePlan {
|
||||||
docker: boolean;
|
docker: boolean;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
@@ -310,6 +327,8 @@ export interface ReleasePlan {
|
|||||||
items: ReleaseItem[];
|
items: ReleaseItem[];
|
||||||
/** Why a release cannot run at all; null when it can. */
|
/** Why a release cannot run at all; null when it can. */
|
||||||
blocked: string | null;
|
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. */
|
/** A slice of the running release's output: what the window has not shown yet. */
|
||||||
@@ -330,6 +349,7 @@ export const MOCK_SCENARIOS = [
|
|||||||
"first-run",
|
"first-run",
|
||||||
"update-required",
|
"update-required",
|
||||||
"ready",
|
"ready",
|
||||||
|
"new-pc",
|
||||||
"offline-installed",
|
"offline-installed",
|
||||||
"offline-not-installed",
|
"offline-not-installed",
|
||||||
"installing",
|
"installing",
|
||||||
@@ -338,6 +358,7 @@ export const MOCK_SCENARIOS = [
|
|||||||
"error",
|
"error",
|
||||||
"dev",
|
"dev",
|
||||||
"dev-dhp",
|
"dev-dhp",
|
||||||
|
"dev-no-checkout",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
|
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
|
||||||
@@ -392,8 +413,10 @@ export interface Backend {
|
|||||||
/** Take the tiles the DHP baked while this PC was away. */
|
/** Take the tiles the DHP baked while this PC was away. */
|
||||||
syncTiles(): Promise<void>;
|
syncTiles(): Promise<void>;
|
||||||
releasePlan(): Promise<ReleasePlan>;
|
releasePlan(): Promise<ReleasePlan>;
|
||||||
releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise<void>;
|
releaseRun(password: string, withApi: boolean, withMcp: boolean, withWeb: boolean): Promise<void>;
|
||||||
releaseConsole(since: number): Promise<ReleaseConsoleTail>;
|
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. */
|
/** What the operator has to decide, oldest first. */
|
||||||
getSyncConflicts(): Promise<SyncConflict[]>;
|
getSyncConflicts(): Promise<SyncConflict[]>;
|
||||||
/** Records one decision; resolves with what is left to decide. */
|
/** Records one decision; resolves with what is left to decide. */
|
||||||
|
|||||||
Reference in New Issue
Block a user