This commit is contained in:
Kelorastus
2026-09-29 10:29:52 +02:00
parent 73a8f2c30c
commit 80dd1e7c7d
22 changed files with 1739 additions and 44 deletions
+30
View File
@@ -66,6 +66,8 @@
return { label: "Taking map tiles", tone: "accent", pulse: true, meta: tileMeta };
case "removing":
return { label: "Removing", tone: "accent", pulse: true, meta: rp ? `${rp.index}/${rp.count}` : null };
case "building":
return { label: "Building the checkout", tone: "accent", pulse: true, meta: null };
case "update":
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
case "launch":
@@ -92,6 +94,16 @@
return (unfinished?.items ?? 0) > titles.length || titles.length > 2 ? `${shown}…` : shown;
});
// Said out loud whenever this run is not the installed ODIAN. A dev client looks exactly
// 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 devRun = $derived((s?.dev ?? null) !== null && (launcher.settings?.dev_mode ?? false));
// Named only when it is someone ELSE: running as yourself is not worth a warning.
const devUser = $derived.by(() => {
const asked = launcher.settings?.dev_user?.trim() ?? "";
return devRun && asked && asked !== s?.operator ? asked : "";
});
const tiles = $derived(s?.tiles_waiting ?? null);
const tilesOffered = $derived(tiles !== null && tiles.tiles > 0);
const tilesCount = $derived(tiles === null ? "" : `${formatCount(tiles.tiles)}${tiles.more ? "+" : ""}`);
@@ -209,6 +221,16 @@
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
</p>
{/if}
{#if devRun}
<p class="secondary attention">
<Icon name="alert" size={14} />
<span>
Dev mode: ODIAN runs from your checkout, not from the installed
<strong>{s?.installed ?? "version"}</strong>.{#if devUser}
Running as <strong>{devUser}</strong> — sync is off.{/if}
</span>
</p>
{/if}
{#if unfinished}
<p class="secondary attention">
<Icon name="alert" size={14} />
@@ -286,6 +308,14 @@
<ProgressBar value={null} label="Syncing with the DHP" />
</div>
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p>
{:else if action === "building"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Compiling ODIAN from your checkout</span>
</div>
<ProgressBar value={null} label="Building" />
</div>
<p class="secondary">The build's own window has its output. A first build takes a while.</p>
{:else if action === "removing"}
<div class="progress">
<div class="stage-line">
+169 -7
View File
@@ -10,7 +10,17 @@
let { open = $bindable(false) }: Props = $props();
const EMPTY: Settings = { site_url: "", mcp_url: "", ai_url: "", install_dir: "" };
const EMPTY: Settings = {
site_url: "",
mcp_url: "",
ai_url: "",
install_dir: "",
dev_root: "",
dev_mode: false,
dev_user: "",
dev_build: false,
dev_target: "client",
};
let dialog: HTMLDialogElement | undefined = $state();
let draft = $state<Settings>({ ...EMPTY });
@@ -33,18 +43,32 @@
mcp_url: draft.mcp_url.trim(),
ai_url: draft.ai_url.trim(),
install_dir: draft.install_dir.trim(),
dev_root: draft.dev_root.trim(),
dev_mode: draft.dev_mode,
dev_user: draft.dev_user.trim(),
dev_build: draft.dev_build,
dev_target: draft.dev_target,
});
const dirty = $derived(
current !== null &&
(trimmed.site_url !== current.site_url ||
trimmed.mcp_url !== current.mcp_url ||
trimmed.ai_url !== current.ai_url ||
trimmed.install_dir !== current.install_dir),
trimmed.install_dir !== current.install_dir ||
trimmed.dev_root !== current.dev_root ||
trimmed.dev_mode !== current.dev_mode ||
trimmed.dev_user !== current.dev_user ||
trimmed.dev_build !== current.dev_build ||
trimmed.dev_target !== current.dev_target),
);
/** A drive's launcher needs a folder on a disk: a root or a share holds no install. */
const dev = $derived(launcher.status?.dev ?? null);
const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir));
const siteError = $derived(urlProblem(trimmed.site_url));
const mcpError = $derived(urlProblem(trimmed.mcp_url));
// Blank is a deliberate answer for all three: no DHP, no sign-in service, no AI. It is
// how a PC is put fully offline on purpose - and how an offline site is rehearsed on a
// PC that does have a line out.
const siteError = $derived(trimmed.site_url === "" ? null : urlProblem(trimmed.site_url));
const mcpError = $derived(trimmed.mcp_url === "" ? null : urlProblem(trimmed.mcp_url));
/** Optional: an empty AI address is how an install with no line out is left. */
const aiError = $derived(trimmed.ai_url === "" ? null : urlProblem(trimmed.ai_url));
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
@@ -53,7 +77,6 @@
);
function urlProblem(value: string): string | null {
if (!value) return "Required.";
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? null : "Use an http:// or https:// address.";
@@ -138,6 +161,19 @@
dialog?.querySelector<HTMLButtonElement>("button:not([disabled])")?.focus();
}
// One stack either way: the web client talks to the same API and database as the .exe,
// which is the whole reason running both at once is worth offering.
const TARGETS = [
{ key: "client", name: "ODIAN", note: "Odian.exe from the checkout" },
{ key: "web", name: "Web", note: "the _WEB dev server on :5173" },
{ key: "both", name: "Both", note: "one stack, two clients, same data" },
] as const;
async function browseDev() {
const path = await launcher.pickFolder();
if (path) draft.dev_root = path;
}
async function browse() {
const path = await launcher.pickFolder();
if (path) draft.install_dir = path;
@@ -186,7 +222,7 @@
aria-describedby="set-site-url-help"
/>
<span id="set-site-url-help" class={dirty && siteError ? "field-error" : "field-help"}>
{dirty && siteError ? siteError : "Update service of your DHP."}
{dirty && siteError ? siteError : "Update service of your DHP. Leave empty to run offline."}
</span>
</div>
<div class="field">
@@ -202,7 +238,7 @@
aria-describedby="set-mcp-url-help"
/>
<span id="set-mcp-url-help" class={dirty && mcpError ? "field-error" : "field-help"}>
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in."}
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in. Leave empty to run offline."}
</span>
</div>
<div class="field">
@@ -258,6 +294,82 @@
</div>
</fieldset>
{#if dev}
<!-- Only ever rendered on a PC holding a valid signed licence. An operator's
launcher has no dev section at all, which is the point of the signature. -->
<section class="group" aria-labelledby="set-dev-title">
<h3 id="set-dev-title" class="eyebrow">Dev</h3>
<p class="text">
{#if dev.machine}This PC{:else}This install{/if} carries a dev licence, valid until
<strong>{dev.expires_at}</strong>{#if dev.note} — {dev.note}{/if}. Dev mode runs the
client from your checkout, in a console, over this install's database and tiles.
</p>
<div class="field">
<label class="field-label" for="set-dev-root">Checkout folder</label>
<div class="row">
<input
id="set-dev-root"
class="input"
type="text"
spellcheck="false"
placeholder="E:\ODIAN_Dev"
bind:value={draft.dev_root}
disabled={locked || busy}
/>
<button type="button" class="btn" disabled={locked || busy} onclick={browseDev}>Browse…</button>
</div>
<p class="hint">The folder that holds <code>ODIAN_Engine</code>.</p>
</div>
<label class="check-row">
<input type="checkbox" bind:checked={draft.dev_mode} disabled={locked || busy || !draft.dev_root.trim()} />
<span>Launch the whole stack from the checkout instead of the installed version</span>
</label>
<label class="check-row">
<input type="checkbox" bind:checked={draft.dev_build} disabled={locked || busy || !draft.dev_mode} />
<span>Build it first, every launch</span>
</label>
<div class="field">
<span class="field-label" id="set-dev-target-label">Launch starts</span>
<div class="choices" role="radiogroup" aria-labelledby="set-dev-target-label">
{#each TARGETS as option (option.key)}
<label class="choice" class:on={draft.dev_target === option.key}>
<input
type="radio"
name="dev-target"
value={option.key}
bind:group={draft.dev_target}
disabled={locked || busy || !draft.dev_mode}
/>
<span class="choice-name">{option.name}</span>
<span class="choice-note">{option.note}</span>
</label>
{/each}
</div>
</div>
<div class="field">
<label class="field-label" for="set-dev-user">Run as</label>
<input
id="set-dev-user"
class="input"
type="text"
spellcheck="false"
placeholder={launcher.status?.operator ?? "the operator who signed in"}
bind:value={draft.dev_user}
disabled={locked || busy || !draft.dev_mode}
/>
<p class="hint">
{#if draft.dev_user.trim() && draft.dev_user.trim() !== launcher.status?.operator}
This PC will <strong>not sync with the DHP</strong> while it runs as someone else: work
authored as them would arrive there under your account.
{:else}
Another operator's name, to test what they see. Sync is switched off while it differs
from yours.
{/if}
</p>
</div>
</section>
{/if}
<section class="group" aria-labelledby="set-drive-title">
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
<p class="text">
@@ -335,6 +447,56 @@
</dialog>
<style>
/* The Dev section's own rows. Only ever drawn on a licensed PC. */
.check-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-top: 0.6rem;
line-height: 1.35;
}
.check-row input {
margin-top: 0.15rem;
flex: none;
}
.hint {
margin: 0.35rem 0 0;
font-size: 0.82rem;
line-height: 1.45;
color: var(--text-dim, #9aa3ad);
}
.choices {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.4rem;
}
.choice {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--border, #2a3139);
border-radius: 6px;
cursor: pointer;
}
.choice.on {
border-color: var(--accent, #5b8cff);
background: color-mix(in srgb, var(--accent, #5b8cff) 12%, transparent);
}
.choice:has(input:disabled) {
opacity: 0.5;
cursor: default;
}
.choice-name {
font-weight: 600;
}
.choice-note {
font-size: 0.8rem;
color: var(--text-dim, #9aa3ad);
}
.drawer {
position: fixed;
inset: 0 0 0 auto;
+7 -1
View File
@@ -21,6 +21,12 @@
failed: "danger",
};
// Dev runs add the web client, and a web-only run has no ODIAN row to show at all.
const shown = $derived.by(() => {
const rows = STACK_ORDER.filter((c) => c !== "odian" || stack.odian || !stack.web);
return stack.web ? [...rows, "web" as StackComponent] : rows;
});
function shownState(component: StackComponent): Shown {
const event = stack[component];
if (event) return event.state;
@@ -30,7 +36,7 @@
</script>
<ol class="stack" aria-label="Local stack">
{#each STACK_ORDER as component, index (component)}
{#each shown as component, index (component)}
{@const state = shownState(component)}
{@const message = stack[component]?.message}
<li class="row {state}">
+2
View File
@@ -76,6 +76,7 @@ export const COMPONENT_LABEL: Record<StackComponent, string> = {
api: "Engine API",
tiles: "Tile server",
odian: "ODIAN",
web: "Web client",
};
export const COMPONENT_DETAIL: Record<StackComponent, string> = {
@@ -83,6 +84,7 @@ export const COMPONENT_DETAIL: Record<StackComponent, string> = {
api: "odian-engine-api",
tiles: "Local tiles",
odian: "Odian.exe",
web: "localhost:5173",
};
export const STACK_STATE_LABEL: Record<StackState | "waiting", string> = {
+7 -1
View File
@@ -32,11 +32,15 @@ export type ActionKind =
| "syncing"
| "pulling_tiles"
| "removing"
| "building"
| "update"
| "launch"
| "stack"
| "error";
// The web client is NOT here: it only exists on a dev run, and a permanent row saying
// "waiting" on every operator PC would be a component they do not have. StackList adds it
// when the backend reports one.
export const STACK_ORDER: readonly StackComponent[] = ["postgres", "api", "tiles", "odian"];
export type StackMap = Record<StackComponent, StackEvent | null>;
@@ -59,6 +63,8 @@ export function deriveAction(s: Status | null): ActionKind {
return "pulling_tiles";
case "removing":
return "removing";
case "building":
return "building";
case "launching":
case "running":
return "stack";
@@ -95,7 +101,7 @@ const LOCKED_PHASES: ReadonlySet<Phase> = new Set([
]);
const MAX_LOGS = 250;
const FOCUS_REFRESH_MS = 30_000;
const emptyStack = (): StackMap => ({ postgres: null, api: null, tiles: null, odian: null });
const emptyStack = (): StackMap => ({ postgres: null, api: null, tiles: null, odian: null, web: null });
const nowSeconds = () => Math.floor(Date.now() / 1000);
class Launcher {
+33 -4
View File
@@ -2,7 +2,8 @@
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
//
// ?scenario=first-run | update-required | ready | offline-installed |
// offline-not-installed | installing | running | sync-conflicts | error
// offline-not-installed | installing | running | sync-conflicts | error |
// dev (a licensed developer PC: the Dev section of Settings appears)
// &fail=sign_in | install | restore | launch | notes make that flow fail
// &speed=4 run timers 4x faster
// &notes=no-roadmap notes without a Roadmap
@@ -247,6 +248,7 @@ function initialStatus(scenario: MockScenario): Status {
map_problem: null,
tiles_waiting: null,
unfinished: null,
dev: null,
};
switch (scenario) {
case "first-run":
@@ -277,6 +279,16 @@ function initialStatus(scenario: MockScenario): Status {
sync: { pending: 1, conflicts: 2, rejected: 1, waiting: 0 },
});
break;
case "dev":
// 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,
operator: OPERATOR,
signed_in: true,
dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" },
});
break;
case "error":
Object.assign(s, {
operator: OPERATOR,
@@ -332,7 +344,10 @@ class MockBackend implements Backend {
this.status = initialStatus(scenario);
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
this.network = this.status.online;
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, ai_url: "", install_dir: DEFAULT_DIR };
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 (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");
@@ -700,9 +715,10 @@ class MockBackend implements Backend {
async setSettings(settings: Settings): Promise<Status> {
// The AI address is optional: empty means the client stays offline.
// All three may be blank: that is how a PC is taken offline on purpose.
for (const [label, url] of ([
["DHP URL", settings.site_url],
["MCP URL", settings.mcp_url],
...(settings.site_url.trim() ? [["DHP URL", settings.site_url] as const] : []),
...(settings.mcp_url.trim() ? [["MCP URL", settings.mcp_url] as const] : []),
...(settings.ai_url.trim() ? [["AI URL", settings.ai_url] as const] : []),
] as const)) {
try {
@@ -714,6 +730,8 @@ class MockBackend implements Backend {
if (!settings.install_dir.trim()) throw "Install folder cannot be empty.";
const siteChanged = settings.site_url !== this.settings.site_url;
this.settings = structuredClone(settings);
// As the backend does: a PC with no licence cannot turn dev mode on, whatever it sends.
if (!this.status.dev) this.settings.dev_mode = false;
this.commit({
site: { url: settings.site_url, name: settings.site_url === SITE_URL ? SITE_NAME : null },
install_dir: settings.install_dir,
@@ -736,9 +754,20 @@ class MockBackend implements Backend {
// The mock has no DHP to take tiles from; the button is simply a no-op there.
}
/** Who dev mode would run as, when that is not the operator who signed in. */
private devSwap(): string | null {
if (!this.status.dev || !this.settings.dev_mode) return null;
const asked = this.settings.dev_user.trim();
return asked && asked !== this.status.operator ? asked : null;
}
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.";
const swap = this.devSwap();
if (swap) {
throw `Dev mode is set to run as ${swap}, so this PC does not sync. Clear the Run as field in Settings to sync as yourself.`;
}
const sent = this.status.sync?.pending ?? 0;
this.commit({ phase: "syncing", error: null });
await this.wait(1400);
+33 -1
View File
@@ -7,6 +7,7 @@ export type Phase =
| "signing_in" // browser sign-in in progress
| "pulling_tiles" // taking tiles the DHP baked while this PC was away
| "removing" // deleting what was unticked in the marketplace
| "building" // dev mode: compiling the checkout before it launches
| "installing" // downloading + verifying a version
| "restoring" // loading the database snapshot into local Postgres
| "syncing" // exchanging operator data with the DHP (ODIAN is not running)
@@ -70,6 +71,20 @@ export interface Status {
* installed or nothing is missing. ODIAN still launches - with less map than was asked for.
*/
unfinished: Unfinished | null;
/**
* This PC's dev licence, when it has a valid one. null on every operator PC — and while
* it is null the window shows no trace of dev mode: no switch, no setting, no mention.
*/
dev: DevLicence | null;
}
/** A signed permission for ONE machine to run ODIAN from source. */
export interface DevLicence {
machine: string;
install_id: string | null;
/** YYYY-MM-DD, the last day it works. */
expires_at: string;
note: string | null;
}
/** What is missing of what was chosen. */
@@ -185,6 +200,19 @@ export interface SyncConflict {
}
export interface Settings {
/** Dev mode: the folder holding the checkouts (it contains ODIAN_Engine). */
dev_root: string;
/** Run the client from `dev_root`. Only honoured on a PC with a valid dev licence. */
dev_mode: boolean;
/** Run as this operator instead of the one signed in. Empty for the signed-in one. */
dev_user: string;
/** Build the checkout before launching. */
dev_build: boolean;
/**
* Which client(s) Launch starts in dev mode. "both" runs them against the one stack —
* same database, same API — which is how a .exe/web divergence becomes visible.
*/
dev_target: DevTarget;
/** e.g. "http://192.168.1.41:30097". */
site_url: string;
/** Site MCP, for Keycloak sign-in. */
@@ -221,7 +249,7 @@ export interface InstallProgress {
message: string | null;
}
export type StackComponent = "postgres" | "api" | "tiles" | "odian";
export type StackComponent = "postgres" | "api" | "tiles" | "odian" | "web";
export type StackState = "starting" | "ready" | "stopped" | "failed";
export interface StackEvent {
@@ -257,6 +285,9 @@ export interface WindowControls {
onResized(handler: () => void): Promise<Unlisten>;
}
export const DEV_TARGETS = ["client", "web", "both"] as const;
export type DevTarget = (typeof DEV_TARGETS)[number];
export const MOCK_SCENARIOS = [
"first-run",
"update-required",
@@ -267,6 +298,7 @@ export const MOCK_SCENARIOS = [
"running",
"sync-conflicts",
"error",
"dev",
] as const;
export type MockScenario = (typeof MOCK_SCENARIOS)[number];