M4
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import KeyArt from "./components/KeyArt.svelte";
|
||||
import NewsPanel from "./components/NewsPanel.svelte";
|
||||
import SettingsDrawer from "./components/SettingsDrawer.svelte";
|
||||
import SyncPanel from "./components/SyncPanel.svelte";
|
||||
import StatusBar from "./components/StatusBar.svelte";
|
||||
import TitleBar from "./components/TitleBar.svelte";
|
||||
import { launcher } from "./lib/launcher.svelte";
|
||||
@@ -31,6 +32,10 @@
|
||||
<div class="activity-slot"><ActivityPanel /></div>
|
||||
{/if}
|
||||
|
||||
{#if launcher.syncOpen}
|
||||
<div class="sync-slot"><SyncPanel /></div>
|
||||
{/if}
|
||||
|
||||
<SettingsDrawer bind:open={settingsOpen} />
|
||||
</div>
|
||||
|
||||
@@ -83,6 +88,14 @@
|
||||
width: min(640px, calc(100% - 64px));
|
||||
}
|
||||
|
||||
.sync-slot {
|
||||
position: absolute;
|
||||
z-index: 31;
|
||||
right: 32px;
|
||||
bottom: calc(var(--statusbar-h) + 10px);
|
||||
width: min(560px, calc(100% - 64px));
|
||||
}
|
||||
|
||||
@media (max-width: 1140px) {
|
||||
.hub {
|
||||
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr) minmax(340px, 380px);
|
||||
@@ -92,6 +105,9 @@
|
||||
.activity-slot {
|
||||
left: 24px;
|
||||
}
|
||||
.sync-slot {
|
||||
right: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
return { label: s?.installed ? "Updating" : "Installing", tone: "accent", pulse: true, meta: stage };
|
||||
case "restoring":
|
||||
return { label: "Restoring database", tone: "accent", pulse: true, meta: stage };
|
||||
case "syncing":
|
||||
return { label: "Syncing with the DHP", tone: "accent", pulse: true, meta: version(s?.installed) };
|
||||
case "update":
|
||||
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
|
||||
case "launch":
|
||||
@@ -165,6 +167,21 @@
|
||||
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
|
||||
{/if}
|
||||
</p>
|
||||
{#if launcher.syncAttention > 0}
|
||||
<p class="secondary attention">
|
||||
<Icon name="alert" size={14} />
|
||||
<span>
|
||||
<strong>{launcher.syncAttention}</strong>
|
||||
{launcher.syncAttention === 1 ? "change needs" : "changes need"} your decision.
|
||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Review</button>
|
||||
</span>
|
||||
</p>
|
||||
{:else if s?.online || s?.sync}
|
||||
<p class="secondary small">
|
||||
Operator data syncs with {site} before every launch.
|
||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
|
||||
</p>
|
||||
{/if}
|
||||
{:else if action === "installing"}
|
||||
<div class="progress">
|
||||
<div class="stage-line">
|
||||
@@ -211,6 +228,14 @@
|
||||
{#if p?.stage === "restore" && p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if}
|
||||
</div>
|
||||
<p class="secondary">This can take a few minutes. Keep the launcher open until it finishes.</p>
|
||||
{:else if action === "syncing"}
|
||||
<div class="progress">
|
||||
<div class="stage-line">
|
||||
<span class="stage-name">Exchanging operator data</span>
|
||||
</div>
|
||||
<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 === "stack"}
|
||||
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
|
||||
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
|
||||
@@ -371,6 +396,16 @@
|
||||
.secondary.small {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.attention {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.attention :global(.icon) {
|
||||
margin-top: 3px;
|
||||
color: var(--warn);
|
||||
}
|
||||
.secondary strong {
|
||||
color: var(--ink-2);
|
||||
font-weight: 500;
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
<span>Installed</span>
|
||||
<strong class="mono">{s.installed ?? "none"}</strong>
|
||||
</span>
|
||||
{#if s.live !== null}
|
||||
<span class="item" title={s.live ? `ODIAN is reading and writing ${launcher.siteName}'s data` : "ODIAN is working from this PC's data"}>
|
||||
<Icon name={s.live ? "globe" : "folder"} size={14} />
|
||||
<span>{s.live ? `Live on ${launcher.siteName}` : "This PC's data"}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if s.sync}
|
||||
<button class="item sync-item" aria-controls="sync-panel" onclick={() => launcher.openSync()}>
|
||||
<Icon name="refresh" size={14} />
|
||||
<span><strong class="mono">{s.sync.pending}</strong> to send</span>
|
||||
{#if s.sync.conflicts + s.sync.rejected > 0}
|
||||
<strong class="review mono">{s.sync.conflicts + s.sync.rejected} to review</strong>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<span class="item dir" title={s.install_dir}>
|
||||
<Icon name="folder" size={14} />
|
||||
<span class="mono selectable path">{s.install_dir}</span>
|
||||
@@ -122,6 +137,28 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sync-item {
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.sync-item:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.sync-item:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.item strong.review {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.dir {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<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 type { ConflictChoice, SyncConflict } from "../lib/types";
|
||||
|
||||
const KIND: Record<string, string> = {
|
||||
db_ground_poi: "POI",
|
||||
db_ground_target: "Target",
|
||||
db_ground_area: "Area",
|
||||
db_ground_line: "Line",
|
||||
odb_note: "Note",
|
||||
odb_folder: "Folder",
|
||||
odb_images: "Image",
|
||||
odb_sitac_operation: "Operation",
|
||||
odb_sitac_operation_snapshot: "Operation state",
|
||||
odb_target_list: "Target list",
|
||||
odb_user_prefs: "Preferences",
|
||||
odb_user_layouts: "Layouts",
|
||||
};
|
||||
|
||||
const s = $derived(launcher.status);
|
||||
const disabled = $derived(launcher.busy !== null || launcher.locked);
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
const asRow = (value: unknown): Row | null =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Row) : null;
|
||||
|
||||
function kind(c: SyncConflict): string {
|
||||
return KIND[c.tbl] ?? c.tbl.replace(/^o?db_/, "").replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function title(c: SyncConflict): string {
|
||||
for (const side of [c.mine, c.theirs, c.base]) {
|
||||
const row = asRow(side);
|
||||
const name = row?.name ?? row?.title;
|
||||
if (typeof name === "string" && name) return name;
|
||||
}
|
||||
return Object.values(c.key).join(" · ");
|
||||
}
|
||||
|
||||
function shown(value: unknown): string {
|
||||
if (value === undefined || value === null) return "—";
|
||||
return typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
function rows(c: SyncConflict) {
|
||||
return c.fields
|
||||
.filter((field) => !field.startsWith("*"))
|
||||
.map((field) => ({ field, mine: shown(asRow(c.mine)?.[field]), theirs: shown(asRow(c.theirs)?.[field]) }));
|
||||
}
|
||||
|
||||
function explain(c: SyncConflict): string | null {
|
||||
if (c.kind === "rejected") return `The DHP refused this change: ${c.reason ?? "no reason given"}.`;
|
||||
if (c.fields.includes("*exists")) {
|
||||
return c.mine === null
|
||||
? "You deleted this on this PC; someone changed it on the DHP."
|
||||
: "You changed this on this PC; someone deleted it on the DHP.";
|
||||
}
|
||||
if (c.fields.includes("*value")) return "It changed differently on this PC and on the DHP.";
|
||||
return "The same fields changed on this PC and on the DHP.";
|
||||
}
|
||||
|
||||
function choices(c: SyncConflict): [ConflictChoice, string][] {
|
||||
if (c.kind === "rejected") {
|
||||
return [
|
||||
["theirs", "Undo my change"],
|
||||
["dismiss", "Keep it on this PC only"],
|
||||
];
|
||||
}
|
||||
if (c.fields.includes("*exists")) {
|
||||
return c.mine === null
|
||||
? [
|
||||
["mine", "Delete it everywhere"],
|
||||
["theirs", "Keep the DHP's version"],
|
||||
]
|
||||
: [
|
||||
["mine", "Keep my version"],
|
||||
["theirs", "Accept the delete"],
|
||||
];
|
||||
}
|
||||
return [
|
||||
["mine", "Keep this PC's"],
|
||||
["theirs", "Take the DHP's"],
|
||||
];
|
||||
}
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && !event.defaultPrevented && !document.querySelector("dialog[open]")) launcher.closeSync();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
<section
|
||||
id="sync-panel"
|
||||
class="sync glass"
|
||||
aria-label="Sync review"
|
||||
transition:fly={{ y: 8, duration: prefersReducedMotion.current ? 0 : 200 }}
|
||||
>
|
||||
<header class="head">
|
||||
<h2 class="eyebrow">Sync</h2>
|
||||
{#if launcher.conflicts?.length}<span class="total mono">{launcher.conflicts.length}</span>{/if}
|
||||
<span class="grow"></span>
|
||||
<button class="btn small" disabled={disabled || !s?.online} onclick={() => launcher.syncNow()}>
|
||||
{launcher.busy === "sync" ? "Syncing…" : "Sync now"}
|
||||
</button>
|
||||
<button class="icon-btn" aria-label="Close sync review" onclick={() => launcher.closeSync()}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if s?.sync}
|
||||
<p class="summary">
|
||||
<span><strong class="mono">{s.sync.pending}</strong> waiting to send</span>
|
||||
{#if s.sync.waiting > 0}<span><strong class="mono">{s.sync.waiting}</strong> DHP changes not applied yet</span>{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if launcher.locked}
|
||||
<p class="empty">Decisions can be made once ODIAN has stopped.</p>
|
||||
{:else if launcher.conflicts === null}
|
||||
<p class="empty">{launcher.busy === "conflicts" ? "Reading this PC's database…" : "Nothing loaded yet."}</p>
|
||||
{:else if launcher.conflicts.length === 0}
|
||||
<p class="empty">Nothing needs your decision. Operator data syncs before every launch and after ODIAN closes.</p>
|
||||
{:else}
|
||||
<ol class="list" aria-label="Changes that need a decision">
|
||||
{#each launcher.conflicts as c (c.tbl + JSON.stringify(c.key))}
|
||||
<li class="item">
|
||||
<div class="item-head">
|
||||
<span class="kind mono">{kind(c)}</span>
|
||||
<span class="name selectable" title={title(c)}>{title(c)}</span>
|
||||
<span class="tag" class:refused={c.kind === "rejected"}>{c.kind === "rejected" ? "Refused" : "Conflict"}</span>
|
||||
</div>
|
||||
<p class="why">{explain(c)}</p>
|
||||
{#if rows(c).length}
|
||||
<div class="diff-wrap">
|
||||
<table class="diff">
|
||||
<thead>
|
||||
<tr><th scope="col">Field</th><th scope="col">This PC</th><th scope="col">DHP</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows(c) as row (row.field)}
|
||||
<tr>
|
||||
<th scope="row" class="mono">{row.field}</th>
|
||||
<td class="selectable">{row.mine}</td>
|
||||
<td class="selectable">{row.theirs}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
{#each choices(c) as [choice, label] (choice)}
|
||||
<button class="btn small" {disabled} onclick={() => launcher.resolveConflict(c, choice)}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.sync {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(520px, 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);
|
||||
}
|
||||
|
||||
.total {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.small {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.summary strong {
|
||||
color: var(--ink-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.item + .item {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kind {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.tag.refused {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.why {
|
||||
color: var(--ink-2);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diff-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.diff {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.diff th,
|
||||
.diff td {
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.diff thead th {
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.diff tbody tr + tr {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.diff tbody th {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.diff td {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
SignInResult,
|
||||
StackEvent,
|
||||
Status,
|
||||
SyncConflict,
|
||||
Unlisten,
|
||||
} from "./types";
|
||||
|
||||
@@ -52,6 +53,10 @@ function createTauriBackend(): Backend {
|
||||
getSettings: () => invoke<Settings>("get_settings"),
|
||||
setSettings: (settings) => invoke<Status>("set_settings", { settings }),
|
||||
pickFolder: () => invoke<string | null>("pick_folder"),
|
||||
syncNow: () => invoke<void>("sync_now"),
|
||||
getSyncConflicts: () => invoke<SyncConflict[]>("get_sync_conflicts"),
|
||||
resolveSyncConflict: (tbl, key, choice, value) =>
|
||||
invoke<SyncConflict[]>("resolve_sync_conflict", { tbl, key, choice, value: value === undefined ? null : value }),
|
||||
|
||||
onStatus: (handler) => subscribe<Status>("status", handler),
|
||||
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { backend } from "./backend";
|
||||
import { errorText, hostOf } from "./format";
|
||||
import { SIGN_IN_CANCELLED } from "./types";
|
||||
import type {
|
||||
ConflictChoice,
|
||||
InstallProgress,
|
||||
LogEvent,
|
||||
LogLevel,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
StackComponent,
|
||||
StackEvent,
|
||||
Status,
|
||||
SyncConflict,
|
||||
Unlisten,
|
||||
} from "./types";
|
||||
|
||||
@@ -24,6 +26,7 @@ export type ActionKind =
|
||||
| "signed_out_offline"
|
||||
| "installing"
|
||||
| "restoring"
|
||||
| "syncing"
|
||||
| "update"
|
||||
| "launch"
|
||||
| "stack"
|
||||
@@ -45,6 +48,8 @@ export function deriveAction(s: Status | null): ActionKind {
|
||||
return "installing";
|
||||
case "restoring":
|
||||
return "restoring";
|
||||
case "syncing":
|
||||
return "syncing";
|
||||
case "launching":
|
||||
case "running":
|
||||
return "stack";
|
||||
@@ -75,6 +80,7 @@ const LOCKED_PHASES: ReadonlySet<Phase> = new Set([
|
||||
"signing_in",
|
||||
"installing",
|
||||
"restoring",
|
||||
"syncing",
|
||||
"launching",
|
||||
"running",
|
||||
]);
|
||||
@@ -104,6 +110,10 @@ class Launcher {
|
||||
busy = $state<string | null>(null);
|
||||
refreshing = $state(false);
|
||||
stopping = $state(false);
|
||||
/** The sync review panel. */
|
||||
syncOpen = $state(false);
|
||||
/** Decisions waiting in this PC's database; null until the panel loads them. */
|
||||
conflicts = $state<SyncConflict[] | null>(null);
|
||||
notes = $state<NotesState>({
|
||||
key: null,
|
||||
loading: false,
|
||||
@@ -117,6 +127,8 @@ class Launcher {
|
||||
/** Settings can't change while something is installing or running. */
|
||||
locked = $derived(this.status ? LOCKED_PHASES.has(this.status.phase) : false);
|
||||
siteName = $derived(this.status ? (this.status.site.name ?? hostOf(this.status.site.url)) : "");
|
||||
/** Conflicts plus refused edits from the last sync: what needs the operator. */
|
||||
syncAttention = $derived(this.status?.sync ? this.status.sync.conflicts + this.status.sync.rejected : 0);
|
||||
notesVersion = $derived(
|
||||
this.status ? (this.status.online ? (this.status.available ?? this.status.installed) : this.status.installed) : null,
|
||||
);
|
||||
@@ -339,6 +351,32 @@ class Launcher {
|
||||
});
|
||||
}
|
||||
|
||||
syncNow() {
|
||||
return this.run("sync", async () => {
|
||||
await backend.syncNow();
|
||||
// The panel lists what the sync just found (or settled).
|
||||
if (this.syncOpen) this.conflicts = await backend.getSyncConflicts();
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the review panel and loads what waits for a decision. */
|
||||
async openSync() {
|
||||
this.syncOpen = true;
|
||||
await this.run("conflicts", async () => {
|
||||
this.conflicts = await backend.getSyncConflicts();
|
||||
});
|
||||
}
|
||||
|
||||
closeSync() {
|
||||
this.syncOpen = false;
|
||||
}
|
||||
|
||||
resolveConflict(conflict: SyncConflict, choice: ConflictChoice, value?: unknown) {
|
||||
return this.run("resolve", async () => {
|
||||
this.conflicts = await backend.resolveSyncConflict(conflict.tbl, conflict.key, choice, value);
|
||||
});
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
try {
|
||||
return await backend.pickFolder();
|
||||
|
||||
+106
-2
@@ -2,7 +2,7 @@
|
||||
// `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
|
||||
// offline-not-installed | installing | running | sync-conflicts | 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
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SIGN_IN_CANCELLED,
|
||||
type Backend,
|
||||
type InstallProgress,
|
||||
type ConflictChoice,
|
||||
type InstallStage,
|
||||
type LogEvent,
|
||||
type LogLevel,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
type StackComponent,
|
||||
type StackEvent,
|
||||
type Status,
|
||||
type SyncConflict,
|
||||
type SyncStatus,
|
||||
type Unlisten,
|
||||
} from "./types";
|
||||
|
||||
@@ -131,6 +134,42 @@ const STAGE_DONE: Record<InstallStage, string> = {
|
||||
finalize: "Install finalized",
|
||||
};
|
||||
|
||||
const POI = "poi_0192f3a1c4e87b5d9a2e6f10b3c4d5e6";
|
||||
const TARGET = "tar_0192f3a2d91c7e4fa6b80c1d2e3f4a5b";
|
||||
const AREA = "are_0192f3a3e0b1c2d3e4f5a6b7c8d9e0f1";
|
||||
const MOCK_CONFLICTS: SyncConflict[] = [
|
||||
{
|
||||
tbl: "db_ground_poi",
|
||||
key: { poi_id: POI },
|
||||
kind: "conflict",
|
||||
fields: ["name", "color"],
|
||||
base: { poi_id: POI, name: "Relay hill", color: "red", lat: 48.61, lon: 2.35 },
|
||||
mine: { poi_id: POI, name: "Relay hill (north slope)", color: "orange", lat: 48.61, lon: 2.35 },
|
||||
theirs: { poi_id: POI, name: "Relay hill, confirmed", color: "yellow", lat: 48.61, lon: 2.35 },
|
||||
reason: null,
|
||||
},
|
||||
{
|
||||
tbl: "db_ground_target",
|
||||
key: { target_id: TARGET },
|
||||
kind: "conflict",
|
||||
fields: ["*exists"],
|
||||
base: { target_id: TARGET, name: "Bridge 14", status_id: null },
|
||||
mine: null,
|
||||
theirs: { target_id: TARGET, name: "Bridge 14", status_id: "st_damaged" },
|
||||
reason: null,
|
||||
},
|
||||
{
|
||||
tbl: "db_ground_area",
|
||||
key: { area_id: AREA },
|
||||
kind: "rejected",
|
||||
fields: [],
|
||||
base: null,
|
||||
mine: { area_id: AREA, name: "NFA Delta (wider)" },
|
||||
theirs: { area_id: AREA, name: "NFA Delta" },
|
||||
reason: "only the owner can change or delete this",
|
||||
},
|
||||
];
|
||||
|
||||
type Payloads = {
|
||||
status: Status;
|
||||
"install-progress": InstallProgress;
|
||||
@@ -173,6 +212,8 @@ function initialStatus(scenario: MockScenario): Status {
|
||||
phase: "idle",
|
||||
error: null,
|
||||
install_dir: DEFAULT_DIR,
|
||||
sync: null,
|
||||
live: null,
|
||||
};
|
||||
switch (scenario) {
|
||||
case "first-run":
|
||||
@@ -193,7 +234,15 @@ function initialStatus(scenario: MockScenario): Status {
|
||||
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" });
|
||||
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true, phase: "running", live: true });
|
||||
break;
|
||||
case "sync-conflicts":
|
||||
Object.assign(s, {
|
||||
installed: CURRENT,
|
||||
operator: OPERATOR,
|
||||
signed_in: true,
|
||||
sync: { pending: 1, conflicts: 2, rejected: 1, waiting: 0 },
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
Object.assign(s, {
|
||||
@@ -225,6 +274,8 @@ class MockBackend implements Backend {
|
||||
private job = 0;
|
||||
private booted = false;
|
||||
private signInCancelled = false;
|
||||
/** What waits for a decision in the mock "local database". */
|
||||
private conflicts: SyncConflict[];
|
||||
/** Latest event per component, as `get_stack` reports it. */
|
||||
private readonly stackStates = new Map<StackComponent, StackEvent>();
|
||||
private readonly listeners: Listeners = {
|
||||
@@ -241,6 +292,7 @@ class MockBackend implements Backend {
|
||||
private readonly notesVariant: NotesVariant,
|
||||
) {
|
||||
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, install_dir: DEFAULT_DIR };
|
||||
|
||||
@@ -475,6 +527,58 @@ class MockBackend implements Backend {
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
private syncCounts(pending: number): SyncStatus {
|
||||
return {
|
||||
pending,
|
||||
conflicts: this.conflicts.filter((c) => c.kind === "conflict").length,
|
||||
rejected: this.conflicts.filter((c) => c.kind === "rejected").length,
|
||||
waiting: 0,
|
||||
};
|
||||
}
|
||||
|
||||
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 sent = this.status.sync?.pending ?? 0;
|
||||
this.commit({ phase: "syncing", error: null });
|
||||
await this.wait(1400);
|
||||
const counts = this.syncCounts(0);
|
||||
this.commit({ phase: "idle", sync: counts });
|
||||
this.log(
|
||||
"info",
|
||||
`Synced with the DHP: ${sent} change(s) sent, 4 received. 0 waiting to send, ${counts.conflicts} conflict(s), ${counts.rejected} refused.`,
|
||||
);
|
||||
}
|
||||
|
||||
async getSyncConflicts(): Promise<SyncConflict[]> {
|
||||
await this.wait(300);
|
||||
return structuredClone(this.conflicts);
|
||||
}
|
||||
|
||||
async resolveSyncConflict(
|
||||
tbl: string,
|
||||
key: Record<string, unknown>,
|
||||
choice: ConflictChoice,
|
||||
): Promise<SyncConflict[]> {
|
||||
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: review sync once it has stopped.";
|
||||
const index = this.conflicts.findIndex((c) => c.tbl === tbl && JSON.stringify(c.key) === JSON.stringify(key));
|
||||
if (index < 0) throw `Nothing to resolve for ${tbl}.`;
|
||||
const conflict = this.conflicts[index];
|
||||
if (choice === "dismiss" && conflict.kind !== "rejected") throw `"dismiss" is not a valid decision for a conflict`;
|
||||
await this.wait(250);
|
||||
this.conflicts.splice(index, 1);
|
||||
const pending = (this.status.sync?.pending ?? 0) + (choice === "mine" || choice === "value" ? 1 : 0);
|
||||
this.commit({ sync: this.syncCounts(pending) });
|
||||
const outcome =
|
||||
choice === "theirs"
|
||||
? "took the DHP's version"
|
||||
: choice === "dismiss"
|
||||
? "kept the refused change on this PC only"
|
||||
: "kept this PC's version; the next sync sends it";
|
||||
this.log("info", `${tbl}: ${outcome}`);
|
||||
return structuredClone(this.conflicts);
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
return "E:\\ODIAN";
|
||||
|
||||
@@ -7,6 +7,7 @@ export type Phase =
|
||||
| "signing_in" // browser sign-in in progress
|
||||
| "installing" // downloading + verifying a version
|
||||
| "restoring" // loading the database snapshot into local Postgres
|
||||
| "syncing" // exchanging operator data with the DHP (ODIAN is not running)
|
||||
| "launching" // starting Postgres → API → tile server → Odian.exe
|
||||
| "running" // Odian.exe is open
|
||||
| "error";
|
||||
@@ -37,6 +38,44 @@ export interface Status {
|
||||
error: string | null;
|
||||
/** e.g. "D:\\ODIAN". */
|
||||
install_dir: string;
|
||||
/** Operator-data sync counts from the last sync this session; null before one. */
|
||||
sync: SyncStatus | null;
|
||||
/**
|
||||
* While ODIAN runs: true when its data comes from the DHP (live), false when from
|
||||
* this PC. null when ODIAN is not running, or the DHP offers no Engine API.
|
||||
*/
|
||||
live: boolean | null;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
/** Local edits the DHP has not taken yet. */
|
||||
pending: number;
|
||||
/** Records both sides changed: the operator decides. */
|
||||
conflicts: number;
|
||||
/** Edits the DHP refused (another operator's record, ...). */
|
||||
rejected: number;
|
||||
/** DHP changes this PC could not apply yet. */
|
||||
waiting: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* theirs: take the DHP's version. mine: keep this PC's (the next sync sends it).
|
||||
* value: keep a field-by-field pick. dismiss: keep a refused edit on this PC only.
|
||||
*/
|
||||
export type ConflictChoice = "theirs" | "mine" | "value" | "dismiss";
|
||||
|
||||
export interface SyncConflict {
|
||||
/** Database table, e.g. "db_ground_poi". */
|
||||
tbl: string;
|
||||
key: Record<string, unknown>;
|
||||
kind: "conflict" | "rejected";
|
||||
/** Clashing fields; "*exists": one side deleted it; "*value": changed differently as a whole. */
|
||||
fields: string[];
|
||||
base: unknown;
|
||||
mine: unknown;
|
||||
theirs: unknown;
|
||||
/** Why the DHP refused it (rejected only). */
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
@@ -115,6 +154,7 @@ export const MOCK_SCENARIOS = [
|
||||
"offline-not-installed",
|
||||
"installing",
|
||||
"running",
|
||||
"sync-conflicts",
|
||||
"error",
|
||||
] as const;
|
||||
|
||||
@@ -152,6 +192,17 @@ export interface Backend {
|
||||
getSettings(): Promise<Settings>;
|
||||
setSettings(settings: Settings): Promise<Status>;
|
||||
pickFolder(): Promise<string | null>;
|
||||
/** Push and pull operator data now; only while ODIAN is stopped. */
|
||||
syncNow(): Promise<void>;
|
||||
/** What the operator has to decide, oldest first. */
|
||||
getSyncConflicts(): Promise<SyncConflict[]>;
|
||||
/** Records one decision; resolves with what is left to decide. */
|
||||
resolveSyncConflict(
|
||||
tbl: string,
|
||||
key: Record<string, unknown>,
|
||||
choice: ConflictChoice,
|
||||
value?: unknown,
|
||||
): Promise<SyncConflict[]>;
|
||||
|
||||
onStatus(handler: (status: Status) => void): Promise<Unlisten>;
|
||||
onInstallProgress(handler: (progress: InstallProgress) => void): Promise<Unlisten>;
|
||||
|
||||
Reference in New Issue
Block a user