Merge branch 'main' of http://192.168.1.41:30061/admin/ODIAN_Launcher
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user