Merge branch 'main' of http://192.168.1.41:30061/admin/ODIAN_Launcher
This commit is contained in:
+25
-2
@@ -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 |
@@ -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 & 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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.";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user