ODIAN Launcher: hub, installer, local stack and remembered sign-in (M2)
- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and update progress, local stack status, settings with DHP and MCP addresses. - crates/odian-packs: Rust installer for pack format v1 (signed manifests, resumable verified downloads from a DHP or an ODIAN drive), byte-compatible with ODIAN_MCP_Server's Python odian_packs. - Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe, one Job Object, clean shutdown; database snapshot restore on a fresh PC only. - Keycloak sign-in through the DHP's MCP, remembered until Sign out with a launcher session that renews the Engine token silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+613
@@ -0,0 +1,613 @@
|
||||
// Browser-only stand-in for the Rust backend, so the hub can be designed with
|
||||
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
|
||||
//
|
||||
// ?scenario=first-run | update-required | ready | offline-installed |
|
||||
// offline-not-installed | installing | running | error
|
||||
// &fail=sign_in | install | restore | launch | notes make that flow fail
|
||||
// &speed=4 run timers 4x faster
|
||||
// ¬es=no-roadmap notes without a Roadmap
|
||||
//
|
||||
// Commands reject with a plain string, like Tauri does.
|
||||
import { mockNotes, type NotesVariant } from "./mock-notes";
|
||||
import {
|
||||
MOCK_SCENARIOS,
|
||||
SIGN_IN_CANCELLED,
|
||||
type Backend,
|
||||
type InstallProgress,
|
||||
type InstallStage,
|
||||
type LogEvent,
|
||||
type LogLevel,
|
||||
type MockControls,
|
||||
type MockScenario,
|
||||
type Settings,
|
||||
type SignInResult,
|
||||
type StackComponent,
|
||||
type StackEvent,
|
||||
type Status,
|
||||
type Unlisten,
|
||||
} from "./types";
|
||||
|
||||
const CURRENT = "0.52.0";
|
||||
const PREVIOUS = "0.51.0";
|
||||
const OPERATOR = "m1op";
|
||||
const SITE_URL = "http://192.168.1.41:30097";
|
||||
const SITE_NAME = "DHP Alpha";
|
||||
const MCP_URL = "http://192.168.1.41:30091";
|
||||
const DEFAULT_DIR = "D:\\ODIAN";
|
||||
|
||||
const KB = 1024;
|
||||
const MB = 1024 ** 2;
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
const FAIL_POINTS = ["sign_in", "install", "restore", "launch", "notes"] as const;
|
||||
type FailPoint = (typeof FAIL_POINTS)[number];
|
||||
|
||||
interface StagePlan {
|
||||
stage: InstallStage;
|
||||
/** Simulated wall time; the reported speed stays realistic. */
|
||||
seconds: number;
|
||||
bytes: number;
|
||||
items: number;
|
||||
bytesPerSec: number;
|
||||
message: (done: number, total: number) => string | null;
|
||||
}
|
||||
|
||||
function installPlan(kind: "fresh" | "update", source: "site" | "drive"): StagePlan[] {
|
||||
const rate = source === "drive" ? 468 * MB : 112 * MB;
|
||||
const plan: StagePlan[] = [
|
||||
{
|
||||
stage: "manifest",
|
||||
seconds: 0.8,
|
||||
bytes: 86 * KB,
|
||||
items: 1,
|
||||
bytesPerSec: source === "drive" ? 40 * MB : 2.4 * MB,
|
||||
message: () => `manifest ${CURRENT} (${source === "drive" ? "drive" : SITE_NAME})`,
|
||||
},
|
||||
{
|
||||
stage: "app",
|
||||
seconds: 3,
|
||||
bytes: kind === "update" ? 96 * MB : 412 * MB,
|
||||
items: kind === "update" ? 138 : 1284,
|
||||
bytesPerSec: rate,
|
||||
message: (d, t) => `app: ${d}/${t} files`,
|
||||
},
|
||||
{
|
||||
stage: "db",
|
||||
seconds: 4,
|
||||
bytes: kind === "update" ? 2.3 * GB : 18.6 * GB,
|
||||
items: 1,
|
||||
bytesPerSec: rate,
|
||||
message: () => (kind === "update" ? "db odian_20260912.delta" : "db odian_20260912.dump"),
|
||||
},
|
||||
];
|
||||
if (kind === "fresh") {
|
||||
plan.push(
|
||||
{
|
||||
stage: "src",
|
||||
seconds: 2.5,
|
||||
bytes: 3.4 * GB,
|
||||
items: 412,
|
||||
bytesPerSec: rate,
|
||||
message: (d, t) => `src: ${d}/${t} files`,
|
||||
},
|
||||
{
|
||||
stage: "tiles",
|
||||
seconds: 9,
|
||||
bytes: 212 * GB,
|
||||
items: 1402,
|
||||
bytesPerSec: rate,
|
||||
message: (d, t) => `tiles dark/protomaps_20260423: ${d}/${t} blocks`,
|
||||
},
|
||||
);
|
||||
}
|
||||
plan.push(
|
||||
{
|
||||
stage: "restore",
|
||||
seconds: 5,
|
||||
bytes: 0,
|
||||
items: 231,
|
||||
bytesPerSec: 0,
|
||||
message: (d, t) => `pg_restore: ${d}/${t} tables`,
|
||||
},
|
||||
{
|
||||
stage: "finalize",
|
||||
seconds: 1,
|
||||
bytes: 0,
|
||||
items: 0,
|
||||
bytesPerSec: 0,
|
||||
message: () => "writing install manifest",
|
||||
},
|
||||
);
|
||||
return plan;
|
||||
}
|
||||
|
||||
const STAGE_DONE: Record<InstallStage, string> = {
|
||||
manifest: "Manifest verified",
|
||||
app: "Application files verified",
|
||||
db: "Database snapshot verified",
|
||||
src: "Source data verified",
|
||||
tiles: "Map tiles verified",
|
||||
restore: "Database restored into local Postgres",
|
||||
finalize: "Install finalized",
|
||||
};
|
||||
|
||||
type Payloads = {
|
||||
status: Status;
|
||||
"install-progress": InstallProgress;
|
||||
stack: StackEvent;
|
||||
log: LogEvent;
|
||||
};
|
||||
type Channel = keyof Payloads;
|
||||
type Listeners = { [K in Channel]: Set<(payload: Payloads[K]) => void> };
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const busy = "Busy: wait for the current task to finish.";
|
||||
|
||||
function pick<T extends string>(value: string | null, allowed: readonly T[]): T | null {
|
||||
return value !== null && (allowed as readonly string[]).includes(value) ? (value as T) : null;
|
||||
}
|
||||
|
||||
/** Enforces the contract's derived fields. */
|
||||
function derive(s: Status): Status {
|
||||
const available = s.online ? (s.available ?? CURRENT) : null;
|
||||
const update_required = s.online && s.installed !== null && available !== null && s.installed !== available;
|
||||
return {
|
||||
...s,
|
||||
available,
|
||||
signed_in: s.online && s.signed_in,
|
||||
update_required,
|
||||
can_launch: s.installed !== null && !update_required && s.phase === "idle",
|
||||
};
|
||||
}
|
||||
|
||||
function initialStatus(scenario: MockScenario): Status {
|
||||
const s: Status = {
|
||||
online: true,
|
||||
site: { url: SITE_URL, name: SITE_NAME },
|
||||
operator: null,
|
||||
signed_in: false,
|
||||
installed: null,
|
||||
available: CURRENT,
|
||||
update_required: false,
|
||||
can_launch: false,
|
||||
phase: "idle",
|
||||
error: null,
|
||||
install_dir: DEFAULT_DIR,
|
||||
};
|
||||
switch (scenario) {
|
||||
case "first-run":
|
||||
break;
|
||||
case "update-required":
|
||||
Object.assign(s, { installed: PREVIOUS, operator: OPERATOR, signed_in: true });
|
||||
break;
|
||||
case "ready":
|
||||
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true });
|
||||
break;
|
||||
case "offline-installed":
|
||||
Object.assign(s, { online: false, installed: PREVIOUS, operator: OPERATOR });
|
||||
break;
|
||||
case "offline-not-installed":
|
||||
Object.assign(s, { online: false });
|
||||
break;
|
||||
case "installing":
|
||||
Object.assign(s, { operator: OPERATOR, signed_in: true, phase: "installing" });
|
||||
break;
|
||||
case "running":
|
||||
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true, phase: "running" });
|
||||
break;
|
||||
case "error":
|
||||
Object.assign(s, {
|
||||
operator: OPERATOR,
|
||||
signed_in: true,
|
||||
phase: "error",
|
||||
error:
|
||||
"Tile block 0731 failed verification (sha256 mismatch). Everything already verified was kept; retry to resume.",
|
||||
});
|
||||
break;
|
||||
}
|
||||
return derive(s);
|
||||
}
|
||||
|
||||
class MockBackend implements Backend {
|
||||
readonly kind = "mock" as const;
|
||||
readonly mock: MockControls;
|
||||
readonly window = {
|
||||
minimize: async () => this.log("info", "Minimize pressed (browser mock)"),
|
||||
toggleMaximize: async () => this.log("info", "Maximize pressed (browser mock)"),
|
||||
close: async () => this.log("info", "Close pressed (browser mock: the tab stays open)"),
|
||||
isMaximized: async () => false,
|
||||
onResized: async (): Promise<Unlisten> => () => {},
|
||||
};
|
||||
|
||||
private status: Status;
|
||||
private settings: Settings;
|
||||
private network: boolean;
|
||||
private job = 0;
|
||||
private booted = false;
|
||||
private signInCancelled = false;
|
||||
/** Latest event per component, as `get_stack` reports it. */
|
||||
private readonly stackStates = new Map<StackComponent, StackEvent>();
|
||||
private readonly listeners: Listeners = {
|
||||
status: new Set(),
|
||||
"install-progress": new Set(),
|
||||
stack: new Set(),
|
||||
log: new Set(),
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly scenario: MockScenario,
|
||||
private readonly fail: FailPoint | null,
|
||||
private readonly speed: number,
|
||||
private readonly notesVariant: NotesVariant,
|
||||
) {
|
||||
this.status = initialStatus(scenario);
|
||||
this.network = this.status.online;
|
||||
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR };
|
||||
|
||||
const self = this;
|
||||
this.mock = {
|
||||
get scenario() {
|
||||
return self.scenario;
|
||||
},
|
||||
get online() {
|
||||
return self.network;
|
||||
},
|
||||
setOnline(online: boolean) {
|
||||
self.network = online;
|
||||
self.log(online ? "info" : "warn", online ? "Mock: network restored" : "Mock: network cable pulled");
|
||||
void self.refresh();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── plumbing ────────────────────────────────────────────────────────────
|
||||
|
||||
private emit<K extends Channel>(channel: K, payload: Payloads[K]) {
|
||||
if (channel === "stack") {
|
||||
const event = payload as StackEvent;
|
||||
this.stackStates.set(event.component, structuredClone(event));
|
||||
}
|
||||
for (const handler of this.listeners[channel]) handler(structuredClone(payload));
|
||||
}
|
||||
|
||||
private listen<K extends Channel>(channel: K, handler: (payload: Payloads[K]) => void): Promise<Unlisten> {
|
||||
this.listeners[channel].add(handler);
|
||||
return Promise.resolve(() => void this.listeners[channel].delete(handler));
|
||||
}
|
||||
|
||||
private commit(patch: Partial<Status>) {
|
||||
this.status = derive({ ...this.status, ...patch });
|
||||
this.emit("status", this.status);
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, at = now()) {
|
||||
this.emit("log", { level, message, at });
|
||||
}
|
||||
|
||||
private wait(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms / this.speed));
|
||||
}
|
||||
|
||||
private snapshot(): Status {
|
||||
return structuredClone(this.status);
|
||||
}
|
||||
|
||||
/** Scenario side effects start on the first get_status, once the UI listens. */
|
||||
private boot() {
|
||||
if (this.booted) return;
|
||||
this.booted = true;
|
||||
const t = now();
|
||||
const s = this.status;
|
||||
this.log("info", "ODIAN Launcher started", t - 6);
|
||||
this.log(
|
||||
s.online ? "info" : "warn",
|
||||
s.online
|
||||
? `${SITE_NAME} answered /v1/health (current version ${CURRENT})`
|
||||
: `${SITE_URL} unreachable (2 s timeout): working offline`,
|
||||
t - 5,
|
||||
);
|
||||
if (s.update_required) {
|
||||
this.log("warn", `Installed ${s.installed} is behind the DHP (${CURRENT}): update required before launch`, t - 4);
|
||||
}
|
||||
if (this.scenario === "running") this.log("info", "Odian.exe is running (pid 14212)", t - 3);
|
||||
if (this.scenario === "error" && s.error) this.log("error", s.error, t - 2);
|
||||
if (this.scenario === "installing") {
|
||||
setTimeout(() => void this.runInstall("fresh", "site", undefined, { index: 4, fraction: 0.27 }), 300);
|
||||
}
|
||||
}
|
||||
|
||||
// ── commands ────────────────────────────────────────────────────────────
|
||||
|
||||
async getStatus(): Promise<Status> {
|
||||
this.boot();
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async refresh(): Promise<Status> {
|
||||
const wasIdle = this.status.phase === "idle";
|
||||
const wasOnline = this.status.online;
|
||||
if (wasIdle) this.commit({ phase: "checking" });
|
||||
await this.wait(this.network ? 450 : 2000);
|
||||
const online = this.network;
|
||||
this.commit({
|
||||
online,
|
||||
phase: wasIdle && this.status.phase === "checking" ? "idle" : this.status.phase,
|
||||
});
|
||||
if (online !== wasOnline) {
|
||||
this.log(
|
||||
online ? "info" : "warn",
|
||||
online ? `${SITE_NAME} is reachable again (current version ${CURRENT})` : `${SITE_NAME} unreachable: working offline`,
|
||||
);
|
||||
if (online && this.status.update_required) {
|
||||
this.log("warn", `Update to ${CURRENT} required before launch while connected`);
|
||||
}
|
||||
}
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async signIn(): Promise<SignInResult> {
|
||||
if (!this.status.online) throw "Your DHP is unreachable. Connect to the DHP network to sign in.";
|
||||
if (this.status.phase !== "idle") throw busy;
|
||||
this.commit({ phase: "signing_in", error: null });
|
||||
this.signInCancelled = false;
|
||||
this.log("info", "Opened the system browser for Keycloak sign-in");
|
||||
// Like the real backend, a cancel ends the wait at once instead of after the browser timeout.
|
||||
for (let waited = 0; waited < 2400 && !this.signInCancelled; waited += 100) await this.wait(100);
|
||||
if (this.signInCancelled) {
|
||||
this.signInCancelled = false;
|
||||
throw SIGN_IN_CANCELLED;
|
||||
}
|
||||
// Re-read after the await: settings or network toggles may have changed the phase.
|
||||
if ((this.status.phase as string) !== "signing_in") throw "Sign-in was interrupted.";
|
||||
if (this.fail === "sign_in") {
|
||||
const message = "Sign-in timed out: the browser never called back to the launcher.";
|
||||
this.commit({ phase: "idle", error: message });
|
||||
throw message;
|
||||
}
|
||||
this.commit({ phase: "idle", signed_in: true, operator: OPERATOR });
|
||||
this.log("info", `Signed in as ${OPERATOR}`);
|
||||
return { username: OPERATOR };
|
||||
}
|
||||
|
||||
async cancelSignIn(): Promise<void> {
|
||||
if (this.status.phase !== "signing_in") return;
|
||||
this.signInCancelled = true;
|
||||
this.commit({ phase: "idle" });
|
||||
this.log("info", "Sign-in cancelled");
|
||||
}
|
||||
|
||||
async signOut(): Promise<Status> {
|
||||
this.commit({ signed_in: false, operator: null });
|
||||
this.log("info", "Signed out: this PC no longer remembers the operator");
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async openExternal(url: string): Promise<void> {
|
||||
if (!/^https?:\/\//i.test(url)) throw "Only web links can be opened.";
|
||||
this.log("info", `Opened ${url} in the system browser (browser mock: new tab)`);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
async getStack(): Promise<StackEvent[]> {
|
||||
if (this.scenario === "running" && this.stackStates.size === 0) {
|
||||
for (const component of ["postgres", "api", "tiles", "odian"] as const) {
|
||||
this.stackStates.set(component, { component, state: "ready", message: null });
|
||||
}
|
||||
}
|
||||
return [...this.stackStates.values()].map((event) => structuredClone(event));
|
||||
}
|
||||
|
||||
async getNotes(version?: string): Promise<string> {
|
||||
await this.wait(380);
|
||||
if (this.fail === "notes") throw "Could not load patch notes: the DHP answered HTTP 502.";
|
||||
if (!this.status.online) {
|
||||
if (!this.status.installed) throw "No cached notes: ODIAN is not installed on this machine.";
|
||||
return mockNotes(this.status.installed, this.notesVariant);
|
||||
}
|
||||
return mockNotes(version ?? this.status.available ?? CURRENT, this.notesVariant);
|
||||
}
|
||||
|
||||
async startInstall(): Promise<void> {
|
||||
const s = this.status;
|
||||
if (!s.online) throw "Your DHP is unreachable. Connect to install, or install from a drive.";
|
||||
if (!s.signed_in) throw "Sign in first: downloading from the DHP needs your DHP sign-in.";
|
||||
if (s.phase !== "idle" && s.phase !== "error") throw busy;
|
||||
const kind = s.installed && s.installed !== s.available ? "update" : "fresh";
|
||||
void this.runInstall(kind, "site");
|
||||
}
|
||||
|
||||
async startInstallFromDrive(path: string): Promise<void> {
|
||||
const s = this.status;
|
||||
if (s.phase !== "idle" && s.phase !== "error") throw busy;
|
||||
if (!path.trim()) throw "Pick the root folder of an ODIAN drive.";
|
||||
const kind = s.installed && s.installed !== CURRENT ? "update" : "fresh";
|
||||
void this.runInstall(kind, "drive", path);
|
||||
}
|
||||
|
||||
async cancelInstall(): Promise<void> {
|
||||
if (this.status.phase === "restoring") throw "The database restore cannot be cancelled once it has started.";
|
||||
if (this.status.phase !== "installing") throw "Nothing is installing.";
|
||||
this.job++;
|
||||
this.commit({ phase: "idle" });
|
||||
this.log("warn", "Install cancelled at the next file boundary; everything already verified was kept");
|
||||
}
|
||||
|
||||
async launch(): Promise<void> {
|
||||
const s = this.status;
|
||||
if (!s.can_launch) {
|
||||
throw s.update_required
|
||||
? `Update to ${s.available} before launching while connected.`
|
||||
: "ODIAN is not ready to launch.";
|
||||
}
|
||||
void this.runLaunch();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const phase = this.status.phase;
|
||||
if (phase !== "running" && phase !== "launching") throw "ODIAN is not running.";
|
||||
void this.runStop();
|
||||
}
|
||||
|
||||
async getSettings(): Promise<Settings> {
|
||||
return structuredClone(this.settings);
|
||||
}
|
||||
|
||||
async setSettings(settings: Settings): Promise<Status> {
|
||||
for (const [label, url] of [
|
||||
["DHP URL", settings.site_url],
|
||||
["MCP URL", settings.mcp_url],
|
||||
] as const) {
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
throw `${label} is not a valid URL: ${url}`;
|
||||
}
|
||||
}
|
||||
if (!settings.install_dir.trim()) throw "Install folder cannot be empty.";
|
||||
const siteChanged = settings.site_url !== this.settings.site_url;
|
||||
this.settings = structuredClone(settings);
|
||||
this.commit({
|
||||
site: { url: settings.site_url, name: settings.site_url === SITE_URL ? SITE_NAME : null },
|
||||
install_dir: settings.install_dir,
|
||||
});
|
||||
this.log("info", "Settings saved");
|
||||
if (siteChanged) void this.refresh();
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
return "E:\\ODIAN";
|
||||
}
|
||||
|
||||
onStatus(handler: (status: Status) => void) {
|
||||
return this.listen("status", handler);
|
||||
}
|
||||
onInstallProgress(handler: (progress: InstallProgress) => void) {
|
||||
return this.listen("install-progress", handler);
|
||||
}
|
||||
onStack(handler: (event: StackEvent) => void) {
|
||||
return this.listen("stack", handler);
|
||||
}
|
||||
onLog(handler: (event: LogEvent) => void) {
|
||||
return this.listen("log", handler);
|
||||
}
|
||||
|
||||
// ── flows ───────────────────────────────────────────────────────────────
|
||||
|
||||
private failFlow(message: string) {
|
||||
this.job++;
|
||||
this.commit({ phase: "error", error: message });
|
||||
this.log("error", message);
|
||||
}
|
||||
|
||||
private async runInstall(
|
||||
kind: "fresh" | "update",
|
||||
source: "site" | "drive",
|
||||
path?: string,
|
||||
resume?: { index: number; fraction: number },
|
||||
) {
|
||||
const job = ++this.job;
|
||||
const plan = installPlan(kind, source);
|
||||
this.commit({ phase: "installing", error: null });
|
||||
if (!resume) {
|
||||
const verb = kind === "update" ? `Updating to ODIAN ${CURRENT}` : `Installing ODIAN ${CURRENT}`;
|
||||
this.log("info", source === "drive" ? `${verb} from drive ${path}` : `${verb} from ${SITE_NAME}`);
|
||||
}
|
||||
|
||||
for (let i = resume?.index ?? 0; i < plan.length; i++) {
|
||||
const step = plan[i];
|
||||
const phase = step.stage === "restore" ? "restoring" : "installing";
|
||||
if (this.status.phase !== phase) this.commit({ phase });
|
||||
|
||||
const ticks = Math.max(1, Math.round(step.seconds * 10));
|
||||
const first = resume && i === resume.index ? Math.floor(ticks * resume.fraction) : 0;
|
||||
for (let t = first; t <= ticks; t++) {
|
||||
if (t > first) await this.wait(100);
|
||||
if (job !== this.job) return;
|
||||
const fraction = t / ticks;
|
||||
|
||||
if (source === "site" && !this.network) {
|
||||
return this.failFlow(
|
||||
`Connection to ${SITE_NAME} lost during ${step.stage}. Everything already verified was kept; retry when you are back online.`,
|
||||
);
|
||||
}
|
||||
if (this.fail === "install" && step.stage === (kind === "fresh" ? "tiles" : "db") && fraction >= 0.62) {
|
||||
return this.failFlow(
|
||||
step.stage === "tiles"
|
||||
? "Tile block 0731 failed verification (sha256 mismatch). Everything already verified was kept; retry to resume."
|
||||
: "odian_20260912.delta failed verification (sha256 mismatch). Retry to download it again.",
|
||||
);
|
||||
}
|
||||
if (this.fail === "restore" && step.stage === "restore" && fraction >= 0.4) {
|
||||
return this.failFlow("pg_restore failed on table track_point: could not extend file (No space left on device).");
|
||||
}
|
||||
|
||||
const done = Math.round(step.items * fraction);
|
||||
const wobble = 1 + 0.07 * Math.sin(t / 6) + (Math.random() - 0.5) * 0.06;
|
||||
this.emit("install-progress", {
|
||||
stage: step.stage,
|
||||
stage_index: i + 1,
|
||||
stage_count: plan.length,
|
||||
done_bytes: Math.round(step.bytes * fraction),
|
||||
total_bytes: Math.round(step.bytes),
|
||||
done_items: done,
|
||||
total_items: step.items,
|
||||
bytes_per_sec: step.bytesPerSec > 0 && t < ticks ? Math.round(step.bytesPerSec * wobble) : 0,
|
||||
message: step.message(done, step.items),
|
||||
});
|
||||
}
|
||||
this.log("info", STAGE_DONE[step.stage]);
|
||||
}
|
||||
|
||||
if (job !== this.job) return;
|
||||
this.commit({ phase: "idle", installed: CURRENT });
|
||||
this.log("info", `ODIAN ${CURRENT} is installed in ${this.status.install_dir}`);
|
||||
}
|
||||
|
||||
private async runLaunch() {
|
||||
const job = ++this.job;
|
||||
this.commit({ phase: "launching", error: null });
|
||||
this.log("info", `Starting the local stack for ODIAN ${this.status.installed}`);
|
||||
|
||||
const steps: [StackComponent, number, string][] = [
|
||||
["postgres", 1300, "listening on 127.0.0.1:55432"],
|
||||
["api", 1500, "healthy on 127.0.0.1:18080"],
|
||||
["tiles", 900, "serving 3 tilesets"],
|
||||
["odian", 1700, "Odian.exe pid 14212"],
|
||||
];
|
||||
for (const [component, ms, ready] of steps) {
|
||||
this.emit("stack", { component, state: "starting", message: null });
|
||||
await this.wait(ms);
|
||||
if (job !== this.job) return;
|
||||
if (this.fail === "launch" && component === "api") {
|
||||
this.emit("stack", { component, state: "failed", message: "exit code 1" });
|
||||
this.emit("stack", { component: "postgres", state: "stopped", message: null });
|
||||
return this.failFlow("Engine API exited with code 1: bind 127.0.0.1:18080: address already in use.");
|
||||
}
|
||||
this.emit("stack", { component, state: "ready", message: ready });
|
||||
this.log("info", `${component}: ${ready}`);
|
||||
}
|
||||
this.commit({ phase: "running" });
|
||||
}
|
||||
|
||||
private async runStop() {
|
||||
const job = ++this.job;
|
||||
this.log("info", "Stopping the local stack");
|
||||
for (const component of ["odian", "tiles", "api", "postgres"] as const) {
|
||||
await this.wait(380);
|
||||
if (job !== this.job) return;
|
||||
this.emit("stack", { component, state: "stopped", message: null });
|
||||
}
|
||||
this.commit({ phase: "idle" });
|
||||
this.log("info", "Local stack stopped cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockBackend(params: URLSearchParams): Backend {
|
||||
const scenario = pick(params.get("scenario"), MOCK_SCENARIOS) ?? "first-run";
|
||||
const fail = pick(params.get("fail"), FAIL_POINTS);
|
||||
const speed = Math.min(20, Math.max(0.25, Number(params.get("speed")) || 1));
|
||||
const notes: NotesVariant = params.get("notes") === "no-roadmap" ? "no-roadmap" : "full";
|
||||
return new MockBackend(scenario, fail, speed, notes);
|
||||
}
|
||||
Reference in New Issue
Block a user