clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/184-task-notice-migration
lofi / package / preact / DeviceStatus.tsx
DeviceStatus.tsx 10.3K
1import type { VNode } from "preact";2import { useEffect, useState } from "preact/hooks";3// Package-owned optional diagnostics UI.4import { useDeviceCapabilities } from "./use-device-capabilities.ts";5import { settleUiMutation } from "../runtime/ui-mutation.ts";6import { getPwaState, type PwaState, subscribePwaState } from "../runtime/pwa.ts";7import { PwaActions } from "./PwaActions.tsx";8import {9 getRuntimeDiagnostics,10 runtimeRecreatedEvent,11 subscribeRuntimeDiagnostics,12} from "../runtime/runtime.ts";13import { describeSchemaCompat } from "../runtime/schema-compat.ts";14import { describeStorageFork, dismissStorageFork } from "../runtime/storage-fork.ts";15import { useStorageFork } from "./use-storage-fork.ts";16import { readSession, type Session } from "../runtime/session.ts";17import { readSinkRestoreOutcome } from "../runtime/data-sink.ts";18import { describeStoreStatus, type RuntimeStoreStatus } from "../runtime/store-status.ts";19import { RuntimeRecovery } from "./RuntimeRecovery.tsx";2021// One label/value row inside a category's definition list.22function Row({ label, value }: { label: string; value: string }): VNode {23 return (24 <div>25 <dt>{label}</dt>26 <dd>{value}</dd>27 </div>28 );29}3031const available = (present: boolean) => (present ? "available" : "missing");3233/**34 * The one-line Data sync verdict. The blocked dispositions are first-class —35 * the report must say *why* nothing is syncing, not merely that it is not:36 * the election belongs to another account, the store refused (no schema, or37 * the ticket is no longer accepted), the declared sink's sealed record cannot38 * be opened on this device, or no sync location exists at all. Exported for39 * tests; the entry does not re-export it.40 */41export function describeSyncState(input: {42 syncing: boolean;43 syncAvailable: boolean;44 ownerMismatch: boolean;45 storeState: RuntimeStoreStatus["state"];46 sinkUnopenable: boolean;47}): string {48 if (input.ownerMismatch) return "paused — sync belongs to another account on this device";49 if (input.storeState === "no_schema") return "blocked — store has no schema for this app";50 if (input.storeState === "ticket_rejected") return "blocked — ticket no longer accepted";51 if (input.sinkUnopenable) return "blocked — sync location record unopenable";52 if (input.syncing) return "syncing to your account";53 if (input.syncAvailable) return "available — not yet backed up";54 return "local-only";55}5657/**58 * Renders the Device gate: every subsystem's live status — storage, sync,59 * auth, and PWA — grouped by the module that owns it, so the panel doubles60 * as a map of where to hook in. Optional: it is built entirely on public61 * runtime APIs (device capabilities, PWA state, runtime diagnostics, the62 * session), so an application can replace it with its own status UI over the63 * same sources.64 *65 * @example66 * ```tsx67 * import { DeviceStatus } from "@nzip/lofi/preact";68 *69 * export function SettingsPage() {70 * return <DeviceStatus />;71 * }72 * ```73 *74 * @returns The device diagnostics panel, grouped by owning subsystem.75 */76export function DeviceStatus(): VNode {77 const { report, requestPersistence } = useDeviceCapabilities();78 const fork = useStorageFork();79 const [pwa, setPwa] = useState<PwaState>(getPwaState());80 const [session, setSession] = useState<Session | null>(null);81 const [runtimeDiagnostics, setRuntimeDiagnostics] = useState(getRuntimeDiagnostics());8283 useEffect(() => subscribePwaState(setPwa), []);84 useEffect(85 () => subscribeRuntimeDiagnostics(() => setRuntimeDiagnostics(getRuntimeDiagnostics())),86 [],87 );88 useEffect(() => {89 const refresh = () => setSession(readSession());90 refresh();91 // Re-read after electing to sync or restoring an account recreates the runtime.92 globalThis.addEventListener(runtimeRecreatedEvent, refresh);93 return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh);94 }, []);9596 if (!report) return <p class="device-status">Checking device capabilities…</p>;9798 const synced = Boolean(session?.syncAvailable);99 const ownerMismatch = Boolean(session?.syncOwnerMismatch);100 const syncOwner = runtimeDiagnostics.syncOwner;101 const sinkUnopenable = !session?.sink && readSinkRestoreOutcome() === "unopenable";102 const sinkState = session?.sink103 ? session.sink.source === "declared"104 ? `declared — ${session.sink.label ?? session.sink.host}`105 : "configured (build default)"106 : sinkUnopenable107 ? "declared — record unopenable on this device"108 : "not configured";109 const syncState = describeSyncState({110 syncing: Boolean(session?.syncing),111 syncAvailable: synced,112 ownerMismatch,113 storeState: runtimeDiagnostics.storeStatus.state,114 sinkUnopenable,115 });116 const backupState = session?.backedUp117 ? "recovery phrase"118 : session?.syncAvailable119 ? "not backed up"120 : "local-only";121122 return (123 <section class="device-status" aria-labelledby="device-status-title">124 <header>125 <p class="eyebrow">Device gate</p>126 <h2 id="device-status-title">Deployment status &amp; integration points</h2>127 </header>128129 <div class="device-category">130 <h3>Shared worker &amp; durable storage</h3>131 <p class="device-hook">132 Hook in: <code>@nzip/lofi</code> device capabilities and runtime133 </p>134 <dl>135 <Row136 label="Durable driver"137 value={report.durableDriverSupported ? "supported" : "blocked"}138 />139 <Row label="Secure context" value={report.secureContext ? "secure" : "not secure"} />140 <Row label="OPFS" value={available(report.opfs)} />141 <Row label="SharedWorker" value={available(report.sharedWorker)} />142 <Row label="Web Locks" value={available(report.webLocks)} />143 <Row label="MessageChannel" value={available(report.messageChannel)} />144 <Row label="Storage persistence" value={report.persistentPermission} />145 <Row146 label="Runtime startup"147 value={runtimeDiagnostics.startupFailure?.code ??148 (runtimeDiagnostics.storageState === "persistent-driver-open" ? "ready" : "opening")}149 />150 </dl>151 <RuntimeRecovery failure={runtimeDiagnostics.startupFailure} />152 <button type="button" onClick={() => void settleUiMutation(requestPersistence())}>153 Request storage persistence154 </button>155 <p>156 OPFS capability and eviction protection are separate. A declined persistence request does157 not mean the Jazz driver is using memory.158 </p>159 </div>160161 <div class="device-category">162 <h3>Data sync</h3>163 <p class="device-hook">164 Hook in: <code>@nzip/lofi</code> session and runtime165 </p>166 <dl>167 <Row label="Sync" value={syncState} />168 <Row label="Sync location" value={sinkState} />169 <Row label="Store" value={describeStoreStatus(runtimeDiagnostics.storeStatus)} />170 </dl>171 {ownerMismatch && (172 <p>173 Sync on this device was set up by{" "}174 {syncOwner.state === "mismatch" && syncOwner.owner_user_id175 ? (176 <>177 the account <code>{syncOwner.owner_user_id}</code>178 </>179 )180 : "a different account"}. Nothing connects while the accounts differ, so neither store181 can merge into the other. Stop syncing in the account panel to release this device for182 the current account, or restore the owning account to resume.183 </p>184 )}185 {runtimeDiagnostics.storeStatus.state === "no_schema" && (186 <p>{runtimeDiagnostics.storeStatus.message}</p>187 )}188 {runtimeDiagnostics.storeStatus.state === "ticket_rejected" && (189 <p>190 The node no longer accepts this device's credential — the ticket was revoked, the node191 was reset, or this device's key is gone. Clear the sync location and enroll a fresh192 ticket; local data is intact and pushes up on re-enrollment.193 </p>194 )}195 {sinkUnopenable && (196 <p>197 A sync location is declared on this device, but its sealed record cannot be opened — the198 device key that sealed it is gone, usually a cleared browser store. The device runs199 local-only until re-enrollment. Clear the sync location and enroll a fresh ticket; local200 data is intact and pushes up.201 </p>202 )}203 {!synced && !ownerMismatch && (204 <p>205 Enroll an app-connect ticket from your node to replicate writes to your account — a206 saved ticket autofills from your password manager. Building with{" "}207 <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in <code>.env</code>{" "}208 remains the compiled-in alternative.209 </p>210 )}211 </div>212213 <div class="device-category">214 <h3>Auth</h3>215 <p class="device-hook">216 Hook in: <code>@nzip/lofi</code> auth and session217 </p>218 <dl>219 <Row label="Identity" value="local-first account" />220 <Row label="Backup" value={backupState} />221 <Row label="WebAuthn" value={available(report.webAuthn)} />222 <Row label="PRF (at-rest key)" value={report.prf} />223 </dl>224 </div>225226 <div class="device-category">227 <h3>PWA</h3>228 <p class="device-hook">229 Hook in: <code>@nzip/lofi</code> PWA state230 </p>231 <dl>232 <Row label="Display mode" value={report.displayMode} />233 <Row label="Install" value={pwa.install} />234 <Row label="Service worker" value={pwa.worker} />235 <Row label="Update" value={pwa.update} />236 <Row237 label="Schema compatibility"238 value={describeSchemaCompat(runtimeDiagnostics.schemaCompat)}239 />240 <Row label="Storage container" value={describeStorageFork(fork)} />241 </dl>242 {(runtimeDiagnostics.schemaCompat.state === "data-ahead" ||243 runtimeDiagnostics.schemaCompat.state === "updating") && (244 <p>{runtimeDiagnostics.schemaCompat.message}</p>245 )}246 {fork.state === "fork-detected" && (247 <>248 <p>{fork.message}</p>249 <button type="button" onClick={() => dismissStorageFork()}>250 Dismiss251 </button>252 </>253 )}254 <PwaActions title="Install & updates" />255 </div>256 </section>257 );258}