Compare commits

..

17 Commits

Author SHA1 Message Date
Kelorastus f69ba7c016 fix 2026-10-02 16:53:01 +02:00
Kelorastus 540f694f01 Revert "dev mode: prefer the DHP's data, fall back to the local install"
This reverts commit 53bba10b3e.
2026-10-02 16:19:24 +02:00
Kelorastus ee2d56b705 update 2026-10-02 16:10:51 +02:00
Kelorastus 53bba10b3e dev mode: prefer the DHP's data, fall back to the local install
A dev run is for working on the client, and the data worth working against lives
on the DHP. Deciding this by "is anything installed" guessed wrong both ways: a PC
with an old install silently ran on stale local data, with nothing on screen
saying so, while a PC with none was refused outright.

Now: dev mode + the DHP answering -> the client runs from the checkout against the
DHP's API and tiles, installed or not. No DHP -> the local stack, as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 16:10:07 +02:00
Valentin 7a97afe046 Update tauri.conf.json 2026-10-02 14:36:51 +02:00
Valentin 9d4da39cb6 Update tauri.conf.json 2026-10-02 14:28:06 +02:00
Valentin 4450722428 Update tauri.conf.json 2026-10-02 14:20:36 +02:00
Kelorastus 167dbd6d3a Merge branch 'main' of http://192.168.1.41:30061/admin/ODIAN_Launcher 2026-10-02 13:10:50 +02:00
Kelorastus 30123e5576 update 2026-10-02 13:10:49 +02:00
JIMMY_JFEXP 6af563793b Merge branch 'main' of http://192.168.1.41:30061/admin/ODIAN_Launcher 2026-10-02 13:06:05 +02:00
Kelorastus dbe7d43387 fix 2026-10-02 11:57:37 +02:00
Kelorastus 9a3094bebc fix 2026-10-02 11:43:22 +02:00
Valentin 7843d48c8f Update tauri.conf.json 2026-10-02 11:00:43 +02:00
Kelorastus 3a9de4013f fix 2026-10-01 13:54:02 +02:00
Kelorastus c1a3bf03b8 fix 2026-10-01 11:56:40 +02:00
Kelorastus 064c1b9098 fix 2026-10-01 09:35:18 +02:00
JIMMY_JFEXP 52f77c814c Launcher window: say "Not signed in on this PC" under Launch, with Sign in
The dev drive on another PC still had no live aircraft after that PC's launcher
went live: its API log kept saying "the DHP answered, but this PC has no DHP
sign-in yet", and launcher.json was never rewritten, i.e. no sign-in ever
completed there. Root cause is the window, not the operator: the action panel
offers Sign in only when nothing is installed, an update is due, or no operator
is remembered (actionFor). The operator is remembered IN launcher.json, which
travels with the drive, while the sign-in lives in that PC's Credential
Manager - so a drive on a new PC shows Launch as if nothing were missing, and
Sign in sits in Settings > Account where nothing points to it.

Under Launch, while online and not signed in, the panel now says ODIAN will run
on this PC's own data without live aircraft, with a Sign in link. Launch stays
the button: a sign-in that cannot complete must not stop ODIAN.

New mock scenario `new-pc` (online, installed, operator remembered, no
sign-in). Spot-check: npm run dev -> ?scenario=new-pc shows the line; its Sign
in link runs the sign-in and the line goes, the status bar then reading
"Signed in as". svelte-check: 0 errors.
2026-09-29 16:22:41 +02:00
11 changed files with 721 additions and 112 deletions
+12 -1
View File
@@ -28,8 +28,19 @@ pub async fn release_run(
password: String,
with_api: bool,
with_mcp: bool,
with_web: bool,
) -> 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]
+45 -4
View File
@@ -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,12 +2490,13 @@ impl Launcher {
launcher_live,
launcher_commit,
None,
data,
)
}
/// 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.
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.
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());
@@ -2519,6 +2533,33 @@ impl Launcher {
launcher_next.as_deref(),
with_api && 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,
&self.release_console,
)
+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,
+256 -6
View File
@@ -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,115 @@ 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 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.
pub fn plan(
dev_root: &Path,
@@ -125,6 +237,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 +270,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.
@@ -271,6 +384,7 @@ pub fn run_release(
launcher_version: Option<&str>,
with_api: bool,
with_mcp: bool,
with_web: bool,
sudo: &str,
console: &Shared,
) -> Result<PathBuf, String> {
@@ -317,6 +431,19 @@ pub fn run_release(
));
}
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()
));
}
if with_mcp {
// LAST, always. The MCP serves /download/* and proxies /dhp, so rolling it interrupts
// the very road the steps above publish over; anything still in flight would fail.
@@ -336,18 +463,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: $_\"
@@ -361,16 +548,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());
@@ -446,6 +648,54 @@ 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()]);
// 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]
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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ODIAN Launcher",
"version": "0.1.41",
"version": "0.1.51",
"identifier": "fr.odian.launcher",
"build": {
"frontendDist": "../dist",
+116 -92
View File
@@ -98,6 +98,12 @@
// 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.
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 devRoot = $derived(launcher.settings?.dev_root ?? "");
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.
{/if}
</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}
<p class="secondary attention">
<Icon name="alert" size={14} />
@@ -266,98 +286,6 @@
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
</p>
{/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 &amp; 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}
<p class="secondary attention">
@@ -492,6 +420,102 @@
</p>
{/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 &amp; 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}
<div class="notice" role="alert">
<Icon name="alert" size={14} />
+191 -1
View File
@@ -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
@@ -15,15 +16,57 @@
// Off by default, deliberately: it serves /download/* and proxies /dhp, so it is the one
// step that interrupts the road everything else publishes over.
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() {
await launcher.releaseRun(password, withApi, withMcp);
await launcher.releaseRun(password, withApi, withMcp, withWeb);
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.
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";
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 } {
if (row.dirty) return { text: "uncommitted", tone: "warn" };
if (row.behind === true) return { text: "behind", tone: "go" };
@@ -62,6 +105,77 @@
{/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>
<!-- 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">
<span class:ok={plan.online}>DHP {plan.online ? "✓" : "✗"}</span>
<span class:ok={plan.docker}>Docker {plan.docker ? "✓" : "✗"}</span>
@@ -82,6 +196,13 @@
<em>— every operator is logged out</em>
</span>
</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">
<input type="checkbox" bind:checked={withMcp} disabled={!plan.docker || busy} />
<span>
@@ -108,6 +229,75 @@
</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-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 {
display: flex;
flex-direction: column;
+3 -1
View File
@@ -73,8 +73,10 @@ function createTauriBackend(): Backend {
onStatus: (handler) => subscribe<Status>("status", handler),
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
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 }),
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),
+16 -2
View File
@@ -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 () => {
await backend.releaseRun(password, withApi, withMcp);
await backend.releaseRun(password, withApi, withMcp, withWeb);
this.releaseOpen = false;
// Straight into the console: the release is running with nowhere else to show itself.
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. */
showConsole() {
this.consoleOpen = true;
+56 -3
View File
@@ -1,7 +1,7 @@
// 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.
//
// ?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 |
// dev (a licensed developer PC: the Dev section of Settings appears)
// &fail=sign_in | install | restore | launch | notes make that flow fail
@@ -265,6 +265,11 @@ function initialStatus(scenario: MockScenario): Status {
case "ready":
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true });
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":
Object.assign(s, { online: false, installed: PREVIOUS, operator: OPERATOR });
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" },
});
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":
Object.assign(s, {
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
// 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-no-checkout") Object.assign(this.settings, { dev_root: "", dev_mode: true });
if (this.status.installed) {
// 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");
@@ -800,11 +819,26 @@ class MockBackend implements Backend {
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,
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);
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
@@ -815,7 +849,7 @@ class MockBackend implements Backend {
"engine at 1255873...",
"Nothing that ships has changed. No pack cut.",
"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 ...",
"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 consoleDone: number | null = 0;
+24 -1
View File
@@ -303,6 +303,23 @@ 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[];
/** 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 {
docker: boolean;
online: boolean;
@@ -310,6 +327,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. */
@@ -330,6 +349,7 @@ export const MOCK_SCENARIOS = [
"first-run",
"update-required",
"ready",
"new-pc",
"offline-installed",
"offline-not-installed",
"installing",
@@ -338,6 +358,7 @@ export const MOCK_SCENARIOS = [
"error",
"dev",
"dev-dhp",
"dev-no-checkout",
] as const;
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. */
syncTiles(): Promise<void>;
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>;
/** 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. */