update
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}">
|
||||
|
||||
Reference in New Issue
Block a user