This commit is contained in:
JIMMY_JFEXP
2026-10-02 13:06:05 +02:00
18 changed files with 2648 additions and 143 deletions
+25 -2
View File
@@ -6,6 +6,8 @@
import KeyArt from "./components/KeyArt.svelte";
import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte";
import NewsPanel from "./components/NewsPanel.svelte";
import ReleaseConsole from "./components/ReleaseConsole.svelte";
import ReleasePanel from "./components/ReleasePanel.svelte";
import SettingsDrawer from "./components/SettingsDrawer.svelte";
import SyncPanel from "./components/SyncPanel.svelte";
import StatusBar from "./components/StatusBar.svelte";
@@ -14,6 +16,13 @@
let settingsOpen = $state(false);
// Dev mode drops the patch notes: they describe the INSTALLED version, which is not what
// a dev run starts. With that column gone the art has the whole window to sit in.
const dev = $derived(launcher.devOn);
// A release takes that column back while it runs: its output is the one thing worth
// reading there, and it is where a developer is already looking.
const showConsole = $derived(dev && launcher.consoleOpen);
onMount(() => launcher.start());
</script>
@@ -21,8 +30,12 @@
<KeyArt />
<TitleBar onOpenSettings={() => (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} />
<main class="hub">
<div class="news-slot"><NewsPanel /></div>
<main class="hub" class:dev={dev && !showConsole}>
{#if !dev}
<div class="news-slot"><NewsPanel /></div>
{:else if showConsole}
<div class="news-slot"><ReleaseConsole /></div>
{/if}
<div class="hero-slot"><Hero /></div>
<div class="action-slot"><ActionPanel /></div>
</main>
@@ -37,6 +50,10 @@
<div class="sync-slot"><SyncPanel /></div>
{/if}
{#if launcher.releaseOpen}
<div class="sync-slot"><ReleasePanel /></div>
{/if}
<SettingsDrawer bind:open={settingsOpen} />
<MarketplaceDrawer />
</div>
@@ -69,6 +86,12 @@
min-height: 0;
}
/* No notes column in dev mode: it collapses rather than being hidden, so the hero and the
action panel keep the places they have everywhere else. */
.hub.dev {
grid-template-columns: 0 minmax(0, 1fr) minmax(360px, 404px);
}
.hero-slot {
grid-row: 1;
grid-column: 2 / span 2;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 190 KiB

+135 -77
View File
@@ -98,10 +98,17 @@
// 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");
const devBuild = $derived(launcher.settings?.dev_build ?? false);
const devUserField = $derived(launcher.settings?.dev_user ?? "");
const savingDev = $derived(busy === "settings");
// One stack either way: both clients talk to the same API and the same database, which is
// the only way a .exe/web divergence is visible at all.
@@ -111,6 +118,17 @@
{ key: "both", name: "Both", note: "one stack" },
] as const;
// ---- releasing ------------------------------------------------------------
async function openRelease() {
launcher.releaseOpen = true;
await launcher.loadRelease();
}
// While one runs, this button is the way BACK to its output rather than a way to start a
// second: closing the console otherwise left a running release with nowhere to be seen.
const releasing = $derived(launcher.consoleRunning);
const hasOutput = $derived(launcher.consoleLines.length > 0 && !launcher.consoleOpen);
async function browseCheckout() {
const path = await launcher.pickFolder();
if (path) await launcher.setDev({ dev_root: path });
@@ -197,6 +215,14 @@
<p class="secondary">
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
</p>
<!-- A licensed PC with nothing installed should be running from its checkout against the
DHP. When it cannot, say which single thing is in the way - this screen used to give
no clue at all, which sent people looking at the install folder every time. -->
{#if s?.dhp_blocked}
<p class="secondary small">
<strong>Dev mode:</strong> a run straight against {site} needs no install, but {s.dhp_blocked}
</p>
{/if}
{@render driveLink("Have an ODIAN drive?")}
{:else if action === "offline_unavailable"}
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
@@ -217,6 +243,14 @@
<p class="secondary">
{#if checking}
Checking {site}…
{:else if !s?.installed}
Nothing is installed here: the client comes from your checkout and runs on
<strong>{site}</strong>'s data. You sign in inside ODIAN, not here.
{:else if s?.update_required}
<!-- Only reachable on a dev run: any other PC would be on the "update" action. -->
ODIAN <strong>{s.installed}</strong> is installed, <strong>{s.available}</strong> is on {site}. A dev run starts
your checkout, so it does not need it.
<button class="btn-link" disabled={blocked} onclick={() => launcher.install()}>Update anyway</button>
{:else if s?.online}
ODIAN <strong>{s.installed}</strong> is up to date.
{:else}
@@ -252,78 +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 wrap">
<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 s?.online}
<!-- The stack is the install's, so its tiles and its data are still worth
refreshing from the DHP while there is a line to it. -->
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncTiles()}>Sync tiles</button>
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncNow()}>Sync database</button>
{:else}
<span class="dev-offline mono">offline — tiles and data stay as they are</span>
{/if}
</div>
{#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">
@@ -458,6 +420,99 @@
</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}
<p class="dev-note attention">
Running as <strong>{devUser}</strong> — this PC will not sync with the DHP.
</p>
{/if}
</section>
{/if}
{#if showNotice}
<div class="notice" role="alert">
<Icon name="alert" size={14} />
@@ -546,10 +601,6 @@
align-items: center;
gap: 0.4rem;
}
.dev-row.wrap {
flex-wrap: wrap;
row-gap: 0.3rem;
}
.dev-path {
flex: 1 1 auto;
min-width: 0;
@@ -561,6 +612,11 @@
border: 1px solid var(--line, #2a3139);
border-radius: 6px;
}
.dev-label {
flex: none;
font-size: 0.78rem;
color: var(--text-dim, #9aa3ad);
}
.dev-check {
display: inline-flex;
align-items: center;
@@ -568,7 +624,6 @@
font-size: 0.8rem;
white-space: nowrap;
}
.dev-offline,
.dev-note {
font-size: 0.75rem;
color: var(--text-dim, #9aa3ad);
@@ -577,6 +632,9 @@
.dev-note code {
font-size: 0.72rem;
}
.dev-release {
font-weight: 600;
}
.action {
display: grid;
+21 -1
View File
@@ -15,7 +15,7 @@
const art = $derived(launcher.devOn ? keyartDev : keyart);
</script>
<div class="keyart" aria-hidden="true">
<div class="keyart" class:dev={launcher.devOn} aria-hidden="true">
<div class="frame">
<img class="art" src={art} alt="" draggable="false" />
</div>
@@ -71,9 +71,29 @@
radial-gradient(120% 100% at 62% 50%, transparent 58%, rgba(9, 9, 11, 0.5) 100%);
}
/* Dev mode has no notes panel, so the picture gets the whole window and is centred in
it - and the left-edge fade goes with the panel it was hiding a seam against. */
.keyart.dev .frame {
left: 0;
-webkit-mask-image: none;
mask-image: none;
}
/* The dev picture is 3:2 in a window that is wider than that, so what gets cut is HEIGHT
- 11% of it at the default size. Taken off the middle it clips the top of her head and
the wordmark; taken mostly off the bottom it costs foreground crates the action panel
half-covers anyway. Hence 30% rather than centre. The other picture keeps its own
framing: it was placed against the notes panel, which dev mode does not draw. */
.keyart.dev .art {
object-position: 50% 30%;
}
@media (max-width: 1140px) {
.frame {
left: 384px;
}
.keyart.dev .frame {
left: 0;
}
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
let scroller: HTMLElement | undefined = $state();
const lines = $derived(launcher.consoleLines);
const running = $derived(launcher.consoleRunning);
const exit = $derived(launcher.consoleExit);
// Follow the output, but only while the reader is already at the bottom: scrolling back to
// read an error must not be yanked away by the next line that arrives.
let follow = $state(true);
function onScroll() {
const el = scroller;
if (!el) return;
follow = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
}
$effect(() => {
void lines.length;
if (follow && scroller) scroller.scrollTop = scroller.scrollHeight;
});
// A step header is worth picking out of a wall of build output, as is a failure.
function kind(line: string): string {
if (/^\s*={3}/.test(line)) return "step";
if (/^FAILED:|^\s*Erreur|error[: ]/i.test(line)) return "bad";
if (/nothing to (release|deploy)|No pack cut|Release finished/i.test(line)) return "note";
return "";
}
</script>
<section class="console glass" aria-label="Release output">
<header class="head">
<h2 class="eyebrow">Release</h2>
<span class="state mono" class:live={running} class:bad={!running && exit !== null && exit !== 0}>
{#if running}
running
{:else if exit === null}
idle
{:else if exit === 0}
finished
{:else}
failed ({exit})
{/if}
</span>
<span class="grow"></span>
<span class="total mono">{lines.length}</span>
<button class="icon-btn" aria-label="Close release output" onclick={() => launcher.closeConsole()}>
<Icon name="close" size={14} />
</button>
</header>
<!-- Both are deliberate: `log` is the role for streamed output, and it has to be
focusable or a keyboard cannot scroll back through a failed build. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div bind:this={scroller} class="body mono selectable" onscroll={onScroll} tabindex="0" role="log">
{#if lines.length}
{#each lines as line, i (i)}
<div class="line {kind(line)}">{line || " "}</div>
{/each}
{:else}
<p class="empty">Waiting for the first line…</p>
{/if}
</div>
{#if !running && exit !== null}
<footer class="foot">
{exit === 0 ? "Closing this keeps the output until the next release." : "The full transcript is in state\\release\\release.log."}
</footer>
{/if}
</section>
<style>
.console {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
background: var(--glass-strong);
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--line);
}
.grow {
flex: 1;
}
.total,
.state {
color: var(--muted);
font-size: var(--text-xs);
}
/* The dot earns its place: from across the desk it is the only thing that says whether a
release is still going. */
.state.live::before,
.state.bad::before {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 6px;
border-radius: 50%;
background: var(--accent, #4ade80);
content: "";
vertical-align: middle;
}
.state.live {
color: var(--ink-2);
}
.state.bad {
color: var(--danger);
}
.state.bad::before {
background: var(--danger);
}
.body {
flex: 1;
padding: 8px 0;
overflow-y: auto;
min-height: 0;
font-size: var(--text-xs);
line-height: 1.55;
}
.line {
padding: 0 16px;
color: var(--ink-2);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.line.step {
margin-top: 6px;
color: var(--ink);
font-weight: 600;
}
.line.bad {
color: var(--danger);
}
.line.note {
color: var(--warn);
}
.empty {
padding: 8px 16px;
color: var(--muted);
font-size: var(--text-sm);
}
.foot {
padding: 8px 16px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: var(--text-xs);
}
</style>
+399
View File
@@ -0,0 +1,399 @@
<script lang="ts">
import { fly } from "svelte/transition";
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
// Release button off the bottom - visible, unreachable, and reported as "it doesn't work".
const plan = $derived(launcher.release);
const busy = $derived(launcher.busy === "release");
let password = $state("");
let withApi = $state(true);
// 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, 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" };
if (row.behind === false) return { text: "up to date", tone: "dim" };
return { text: "unknown", tone: "dim" };
}
</script>
<section
class="release glass"
aria-label="Build and deploy"
transition:fly={{ y: 8, duration: prefersReducedMotion.current ? 0 : 200 }}
>
<header class="head">
<Icon name="code" size={14} />
<h2 class="eyebrow">Build &amp; deploy</h2>
<span class="grow"></span>
<button class="icon-btn" aria-label="Close" onclick={() => (launcher.releaseOpen = false)}>
<Icon name="close" size={14} />
</button>
</header>
<div class="body">
{#if !plan}
<p class="note">Looking at what is live and what this PC holds…</p>
{:else}
<ul class="plan">
{#each plan.items as row (row.key)}
{@const st = rowState(row)}
<li>
<span class="name">{row.title}</span>
<span class="live mono">{row.live ?? "—"}</span>
<span class="state {st.tone}">{st.text}</span>
<span class="commit mono">{row.commit ?? ""}</span>
</li>
{/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>
<span class:ok={plan.scripts}>scripts {plan.scripts ? "✓" : "✗"}</span>
</div>
{#if plan.blocked}
<p class="note warn">{plan.blocked}</p>
{:else}
<p class="note">
Each step decides for itself whether it has anything to do, so a press releases only
what has moved. It runs in its own window.
</p>
<label class="check">
<input type="checkbox" bind:checked={withApi} disabled={!plan.docker || busy} />
<span>
Also roll the Engine API{plan.docker ? "" : " — needs Docker"}
<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>
Also deploy the MCP server{plan.docker ? "" : " — needs Docker"}
<em>— runs last; it serves the download feeds</em>
</span>
</label>
<div class="row">
<input
class="input"
type="password"
autocomplete="off"
placeholder="node sudo password"
bind:value={password}
disabled={busy}
/>
<button class="btn primary-ish" disabled={busy || !password.trim()} onclick={release}>
{busy ? "Starting…" : "Release"}
</button>
</div>
{/if}
{/if}
</div>
</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;
max-height: min(460px, calc(100vh - 180px));
background: var(--glass-strong);
box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.8);
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--line);
color: #46d17f;
}
.grow {
flex: 1;
}
.body {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 12px 16px 14px;
overflow: auto;
}
.plan {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.plan li {
display: grid;
grid-template-columns: 6.5rem 4.5rem 6rem 1fr;
gap: 0.5rem;
align-items: baseline;
font-size: 0.8rem;
}
.name {
font-weight: 600;
}
.live,
.commit {
color: var(--muted);
font-size: 0.75rem;
}
.state.go {
color: #46d17f;
font-weight: 600;
}
.state.warn {
color: #e6b35a;
}
.state.dim {
color: var(--muted);
}
.checks {
display: flex;
gap: 0.8rem;
font-size: 0.74rem;
color: #e06c6c;
}
.checks span.ok {
color: #46d17f;
}
.note {
margin: 0;
font-size: 0.78rem;
line-height: 1.45;
color: var(--muted);
}
.note.warn {
color: #e6b35a;
}
.check {
display: flex;
align-items: flex-start;
gap: 0.45rem;
font-size: 0.8rem;
line-height: 1.35;
}
.check em {
color: #e6b35a;
font-style: normal;
}
.row {
display: flex;
gap: 0.4rem;
}
.row .input {
flex: 1 1 auto;
min-width: 0;
}
.primary-ish {
font-weight: 600;
}
</style>
+7
View File
@@ -15,6 +15,8 @@ import type {
SignInResult,
StackEvent,
Status,
ReleaseConsoleTail,
ReleasePlan,
RemoveProgress,
SyncConflict,
TileSyncProgress,
@@ -70,6 +72,11 @@ 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, 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),
+92 -1
View File
@@ -10,6 +10,7 @@ import type {
LogLevel,
Market,
Phase,
ReleasePlan,
Settings,
StackComponent,
StackEvent,
@@ -71,10 +72,16 @@ export function deriveAction(s: Status | null): ActionKind {
}
// idle or checking
if (s.installed === null) {
// Nothing installed, but a dev checkout and a reachable DHP are enough on their own:
// the client runs against the DHP and signs itself in, so neither an install nor a
// launcher sign-in stands in the way.
if (s.dhp_direct) return "launch";
if (!s.online) return "offline_unavailable";
return s.signed_in ? "install" : "sign_in";
}
if (s.update_required) return s.signed_in ? "update" : "sign_in";
// A dev run starts the checkout, so a newer pack is worth saying but not worth
// stopping for; the hub keeps Launch and mentions the update beside it.
if (s.update_required && !s.dev_run) return s.signed_in ? "update" : "sign_in";
// Launching runs ODIAN as the remembered operator; after Sign out there is none.
if (s.operator === null) return s.online ? "sign_in" : "signed_out_offline";
return "launch";
@@ -402,6 +409,90 @@ class Launcher {
return this.launch();
}
/** What a release could do right now; null until the panel asks. */
release = $state<ReleasePlan | null>(null);
releaseOpen = $state(false);
async loadRelease() {
this.release = null;
await this.run("release", async () => {
this.release = await backend.releasePlan();
});
}
releaseRun(password: string, withApi: boolean, withMcp: boolean, withWeb: boolean) {
return this.run("release", async () => {
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 = [];
this.consoleTotal = 0;
this.consoleExit = null;
this.consoleRunning = true;
this.consoleOpen = true;
this.pollConsole();
});
}
// ---- the release console -------------------------------------------------------------
/** Shown in the notes column in dev mode, where the patch notes sit otherwise. */
consoleOpen = $state(false);
consoleLines = $state<string[]>([]);
consoleRunning = $state(false);
consoleExit = $state<number | null>(null);
/** How many lines have been taken, so each poll asks only for what is new. */
private consoleTotal = 0;
private polling = false;
/** Polled rather than pushed: reopening the panel then needs no replay, and a slow window
* cannot fall behind a build that prints thousands of lines. */
private async pollConsole() {
if (this.polling) return;
this.polling = true;
try {
for (;;) {
const tail = await backend.releaseConsole(this.consoleTotal);
if (tail.from > this.consoleTotal && this.consoleLines.length) {
this.consoleLines.push(`... ${tail.from - this.consoleTotal} earlier lines dropped`);
}
if (tail.lines.length) this.consoleLines.push(...tail.lines);
this.consoleTotal = tail.total;
this.consoleRunning = tail.running;
this.consoleExit = tail.exit;
if (!tail.running) return;
await new Promise((r) => setTimeout(r, 400));
}
} catch {
this.consoleRunning = false;
} finally {
this.polling = false;
}
}
/** 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;
if (this.consoleRunning) this.pollConsole();
}
closeConsole() {
this.consoleOpen = false;
}
/** Change part of the settings and save, for the dev controls on the hub. */
setDev(change: Partial<Settings>) {
const current = this.settings;
+132 -3
View File
@@ -24,6 +24,8 @@ import {
type MarketItem,
type MockControls,
type MockScenario,
type ReleaseConsoleTail,
type ReleasePlan,
type Settings,
type SignInResult,
type StackComponent,
@@ -224,7 +226,8 @@ function derive(s: Status): Status {
available,
signed_in: s.online && s.signed_in,
update_required,
can_launch: s.installed !== null && !update_required && s.phase === "idle",
// A dev run launches the checkout, so an available pack does not hold it back.
can_launch: (s.installed !== null || s.dhp_direct) && (!update_required || s.dev_run) && s.phase === "idle",
};
}
@@ -238,6 +241,9 @@ function initialStatus(scenario: MockScenario): Status {
available: CURRENT,
update_required: false,
can_launch: false,
dev_run: false,
dhp_direct: false,
dhp_blocked: null,
phase: "idle",
error: null,
install_dir: DEFAULT_DIR,
@@ -288,12 +294,41 @@ function initialStatus(scenario: MockScenario): Status {
// A licensed PC, already set up to run from a checkout, so the Dev section and the
// dev-run marker can be seen without a licence file.
Object.assign(s, {
installed: CURRENT,
// Deliberately BEHIND what the DHP has: this is the case that used to refuse to
// launch at all, so the scenario should show it working.
installed: "0.71.0",
operator: OPERATOR,
signed_in: true,
dev_run: true,
dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" },
});
break;
case "dev-dhp":
// A licensed dev PC with NOTHING installed, on a reachable DHP: the case that used to
// offer only Install.
Object.assign(s, {
installed: null,
operator: OPERATOR,
signed_in: true,
dev_run: true,
dhp_direct: true,
dhp_blocked: null,
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,
@@ -352,7 +387,8 @@ class MockBackend implements Backend {
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, ai_url: "", install_dir: DEFAULT_DIR, dev_root: "", dev_mode: false, dev_user: "", dev_build: false, dev_target: "client" };
// 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") 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) {
// 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");
@@ -766,6 +802,99 @@ class MockBackend implements Backend {
return asked && asked !== this.status.operator ? asked : null;
}
async releasePlan(): Promise<ReleasePlan> {
await this.wait(200);
const dev = this.status.dev !== null;
return {
docker: dev,
online: this.status.online,
scripts: dev,
blocked: this.status.online ? null : "The DHP is not answering: nothing can be published.",
items: [
{ key: "pack", title: "ODIAN", live: this.status.available, commit: "a1b2c3d", behind: true,
dirty: false, note: "behind: the repo has moved past what is published" },
{ key: "launcher", title: "Launcher", live: "0.1.38", commit: "e4f5a6b", behind: false,
dirty: false, note: "up to date" },
{ key: "api", title: "Engine API", live: null, commit: "a1b2c3d", behind: null,
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, 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
// to see the panel fill, scroll and finish.
this.consoleLines = ["=== ODIAN pack 0.74.0 ===", "", "=== Preflight ==="];
this.consoleDone = null;
const rest = [
"engine at 1255873...",
"Nothing that ships has changed. No pack cut.",
"pack: nothing to release",
`=== Launcher 0.1.40 === (API ${withApi}, MCP ${withMcp}, web ${withWeb})`,
"npx tauri build ...",
"Release finished.",
];
void (async () => {
for (const line of rest) {
await this.wait(700);
this.consoleLines.push(line);
}
this.consoleDone = 0;
})();
}
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;
async releaseConsole(since: number): Promise<ReleaseConsoleTail> {
await this.wait(60);
return {
from: Math.min(since, this.consoleLines.length),
lines: this.consoleLines.slice(since),
total: this.consoleLines.length,
running: this.consoleDone === null,
exit: this.consoleDone,
};
}
async syncNow(): Promise<void> {
if (!this.status.online) throw "Your DHP is unreachable: changes stay on this PC until the next connected sync.";
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: sync once it has stopped.";
+64
View File
@@ -36,6 +36,12 @@ export interface Status {
update_required: boolean;
/** installed && !update_required && phase is idle. */
can_launch: boolean;
/** This launch would come from the dev checkout, so an available pack does not gate it. */
dev_run: boolean;
/** Nothing is installed, but a dev run can still start the client against the DHP. */
dhp_direct: boolean;
/** On a licensed PC with nothing installed, why a DHP run is not on offer. */
dhp_blocked: string | null;
phase: Phase;
/** Last error, human readable. */
error: string | null;
@@ -285,6 +291,57 @@ export interface WindowControls {
onResized(handler: () => void): Promise<Unlisten>;
}
/** What a release could do right now (dev mode only). */
export interface ReleaseItem {
key: string;
title: string;
live: string | null;
commit: string | null;
/** true = the repo has moved past what is published; null = nothing published says. */
behind: boolean | null;
dirty: boolean;
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;
scripts: boolean;
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. */
export interface ReleaseConsoleTail {
/** Index of the first line returned; higher than asked for when older lines were trimmed. */
from: number;
lines: string[];
total: number;
running: boolean;
/** Null while running; 0 means it finished, anything else means it failed. */
exit: number | null;
}
export const DEV_TARGETS = ["client", "web", "both"] as const;
export type DevTarget = (typeof DEV_TARGETS)[number];
@@ -300,6 +357,8 @@ export const MOCK_SCENARIOS = [
"sync-conflicts",
"error",
"dev",
"dev-dhp",
"dev-no-checkout",
] as const;
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
@@ -353,6 +412,11 @@ export interface Backend {
syncNow(): Promise<void>;
/** Take the tiles the DHP baked while this PC was away. */
syncTiles(): Promise<void>;
releasePlan(): Promise<ReleasePlan>;
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. */