clone

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

feat/141-effect-library
lofi / package / preact / DeviceStatus.tsx
DeviceStatus.tsx 11.5K
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 { type CredentialOriginReport, getAuthCapability } from "../runtime/auth.ts";6import { settleUiMutation } from "../runtime/ui-mutation.ts";7import { getPwaState, type PwaState, subscribePwaState } from "../runtime/pwa.ts";8import { PwaActions } from "./PwaActions.tsx";9import {10 getRuntimeDiagnostics,11 runtimeRecreatedEvent,12 subscribeRuntimeDiagnostics,13} from "../runtime/runtime.ts";14import { describeSchemaCompat } from "../runtime/schema-compat.ts";15import { describeStorageFork, dismissStorageFork } from "../runtime/storage-fork.ts";16import { useStorageFork } from "./use-storage-fork.ts";17import { readSession, type Session } from "../runtime/session.ts";18import { readSinkRestoreOutcome } from "../runtime/data-sink.ts";19import { describeStoreStatus, type RuntimeStoreStatus } from "../runtime/store-status.ts";20import { RuntimeRecovery } from "./RuntimeRecovery.tsx";2122// One label/value row inside a category's definition list.23function Row({ label, value }: { label: string; value: string }): VNode {24 return (25 <div>26 <dt>{label}</dt>27 <dd>{value}</dd>28 </div>29 );30}3132const available = (present: boolean) => (present ? "available" : "missing");3334/** One-line credential-origin verdict that distinguishes API support from deployability. */35export function describeCredentialOrigin(origin: CredentialOriginReport): string {36 switch (origin.status) {37 case "stable":38 return `stable — ${origin.rpId}`;39 case "local-only":40 return `local development only — ${origin.rpId}`;41 case "unverified":42 return `unverified — ${origin.rpId}`;43 case "blocked":44 return origin.rpId ? `blocked — ${origin.rpId}` : "blocked";45 }46}4748/**49 * The one-line Data sync verdict. The blocked dispositions are first-class —50 * the report must say *why* nothing is syncing, not merely that it is not:51 * the election belongs to another account, the store refused (no schema, or52 * the ticket is no longer accepted), the declared sink's sealed record cannot53 * be opened on this device, or no sync location exists at all. Exported for54 * tests; the entry does not re-export it.55 */56export function describeSyncState(input: {57 syncing: boolean;58 syncAvailable: boolean;59 ownerMismatch: boolean;60 storeState: RuntimeStoreStatus["state"];61 sinkUnopenable: boolean;62}): string {63 if (input.ownerMismatch) return "paused — sync belongs to another account on this device";64 if (input.storeState === "no_schema") return "blocked — store has no schema for this app";65 if (input.storeState === "ticket_rejected") return "blocked — ticket no longer accepted";66 if (input.sinkUnopenable) return "blocked — sync location record unopenable";67 if (input.syncing) return "syncing to your account";68 if (input.syncAvailable) return "available — not yet backed up";69 return "local-only";70}7172/**73 * Renders the Device gate: every subsystem's live status — storage, sync,74 * auth, and PWA — grouped by the module that owns it, so the panel doubles75 * as a map of where to hook in. Optional: it is built entirely on public76 * runtime APIs (device capabilities, PWA state, runtime diagnostics, the77 * session), so an application can replace it with its own status UI over the78 * same sources.79 *80 * @example81 * ```tsx82 * import { DeviceStatus } from "@nzip/lofi/preact";83 *84 * export function SettingsPage() {85 * return <DeviceStatus />;86 * }87 * ```88 *89 * @returns The device diagnostics panel, grouped by owning subsystem.90 */91export function DeviceStatus(): VNode {92 const { report, requestPersistence } = useDeviceCapabilities();93 const fork = useStorageFork();94 const [pwa, setPwa] = useState<PwaState>(getPwaState());95 const [session, setSession] = useState<Session | null>(null);96 const [runtimeDiagnostics, setRuntimeDiagnostics] = useState(getRuntimeDiagnostics());97 const [credentialOrigin, setCredentialOrigin] = useState<CredentialOriginReport | null>(null);9899 useEffect(() => subscribePwaState(setPwa), []);100 useEffect(101 () => subscribeRuntimeDiagnostics(() => setRuntimeDiagnostics(getRuntimeDiagnostics())),102 [],103 );104 useEffect(() => {105 const refresh = () => setSession(readSession());106 refresh();107 // Re-read after electing to sync or restoring an account recreates the runtime.108 globalThis.addEventListener(runtimeRecreatedEvent, refresh);109 return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh);110 }, []);111 useEffect(() => {112 let active = true;113 void getAuthCapability().then((capability) => {114 if (active) setCredentialOrigin(capability.origin);115 });116 return () => {117 active = false;118 };119 }, []);120121 if (!report) return <p class="device-status">Checking device capabilities…</p>;122123 const synced = Boolean(session?.syncAvailable);124 const ownerMismatch = Boolean(session?.syncOwnerMismatch);125 const syncOwner = runtimeDiagnostics.syncOwner;126 const sinkUnopenable = !session?.sink && readSinkRestoreOutcome() === "unopenable";127 const sinkState = session?.sink128 ? session.sink.source === "declared"129 ? `declared — ${session.sink.label ?? session.sink.host}`130 : "configured (build default)"131 : sinkUnopenable132 ? "declared — record unopenable on this device"133 : "not configured";134 const syncState = describeSyncState({135 syncing: Boolean(session?.syncing),136 syncAvailable: synced,137 ownerMismatch,138 storeState: runtimeDiagnostics.storeStatus.state,139 sinkUnopenable,140 });141 const backupState = session?.backedUp142 ? "recovery phrase"143 : session?.syncAvailable144 ? "not backed up"145 : "local-only";146147 return (148 <section class="device-status" aria-labelledby="device-status-title">149 <header>150 <p class="eyebrow">Device gate</p>151 <h2 id="device-status-title">Deployment status &amp; integration points</h2>152 </header>153154 <div class="device-category">155 <h3>Shared worker &amp; durable storage</h3>156 <p class="device-hook">157 Hook in: <code>@nzip/lofi</code> device capabilities and runtime158 </p>159 <dl>160 <Row161 label="Durable driver"162 value={report.durableDriverSupported ? "supported" : "blocked"}163 />164 <Row label="Secure context" value={report.secureContext ? "secure" : "not secure"} />165 <Row label="OPFS" value={available(report.opfs)} />166 <Row label="SharedWorker" value={available(report.sharedWorker)} />167 <Row label="Web Locks" value={available(report.webLocks)} />168 <Row label="MessageChannel" value={available(report.messageChannel)} />169 <Row label="Storage persistence" value={report.persistentPermission} />170 <Row171 label="Runtime startup"172 value={runtimeDiagnostics.startupFailure?.code ??173 (runtimeDiagnostics.storageState === "persistent-driver-open" ? "ready" : "opening")}174 />175 </dl>176 <RuntimeRecovery failure={runtimeDiagnostics.startupFailure} />177 <button type="button" onClick={() => void settleUiMutation(requestPersistence())}>178 Request storage persistence179 </button>180 <p>181 OPFS capability and eviction protection are separate. A declined persistence request does182 not mean the Jazz driver is using memory.183 </p>184 </div>185186 <div class="device-category">187 <h3>Data sync</h3>188 <p class="device-hook">189 Hook in: <code>@nzip/lofi</code> session and runtime190 </p>191 <dl>192 <Row label="Sync" value={syncState} />193 <Row label="Sync location" value={sinkState} />194 <Row label="Store" value={describeStoreStatus(runtimeDiagnostics.storeStatus)} />195 </dl>196 {ownerMismatch && (197 <p>198 Sync on this device was set up by{" "}199 {syncOwner.state === "mismatch" && syncOwner.owner_user_id200 ? (201 <>202 the account <code>{syncOwner.owner_user_id}</code>203 </>204 )205 : "a different account"}. Nothing connects while the accounts differ, so neither store206 can merge into the other. Stop syncing in the account panel to release this device for207 the current account, or restore the owning account to resume.208 </p>209 )}210 {runtimeDiagnostics.storeStatus.state === "no_schema" && (211 <p>{runtimeDiagnostics.storeStatus.message}</p>212 )}213 {runtimeDiagnostics.storeStatus.state === "ticket_rejected" && (214 <p>215 The node no longer accepts this device's credential — the ticket was revoked, the node216 was reset, or this device's key is gone. Clear the sync location and enroll a fresh217 ticket; local data is intact and pushes up on re-enrollment.218 </p>219 )}220 {sinkUnopenable && (221 <p>222 A sync location is declared on this device, but its sealed record cannot be opened — the223 device key that sealed it is gone, usually a cleared browser store. The device runs224 local-only until re-enrollment. Clear the sync location and enroll a fresh ticket; local225 data is intact and pushes up.226 </p>227 )}228 {!synced && !ownerMismatch && (229 <p>230 Enroll an app-connect ticket from your node to replicate writes to your account — a231 saved ticket autofills from your password manager. Building with{" "}232 <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in <code>.env</code>{" "}233 remains the compiled-in alternative.234 </p>235 )}236 </div>237238 <div class="device-category">239 <h3>Auth</h3>240 <p class="device-hook">241 Hook in: <code>@nzip/lofi</code> auth and session242 </p>243 <dl>244 <Row label="Identity" value="local-first account" />245 <Row label="Backup" value={backupState} />246 <Row label="WebAuthn API" value={available(report.webAuthn)} />247 <Row label="PRF API (at-rest key)" value={report.prf} />248 <Row249 label="Credential origin"250 value={credentialOrigin ? describeCredentialOrigin(credentialOrigin) : "checking"}251 />252 </dl>253 {credentialOrigin && credentialOrigin.status !== "stable" && (254 <p>{credentialOrigin.action}</p>255 )}256 </div>257258 <div class="device-category">259 <h3>PWA</h3>260 <p class="device-hook">261 Hook in: <code>@nzip/lofi</code> PWA state262 </p>263 <dl>264 <Row label="Display mode" value={report.displayMode} />265 <Row label="Install" value={pwa.install} />266 <Row label="Service worker" value={pwa.worker} />267 <Row label="Update" value={pwa.update} />268 <Row269 label="Schema compatibility"270 value={describeSchemaCompat(runtimeDiagnostics.schemaCompat)}271 />272 <Row label="Storage container" value={describeStorageFork(fork)} />273 </dl>274 {(runtimeDiagnostics.schemaCompat.state === "data-ahead" ||275 runtimeDiagnostics.schemaCompat.state === "updating") && (276 <p>{runtimeDiagnostics.schemaCompat.message}</p>277 )}278 {fork.state === "fork-detected" && (279 <>280 <p>{fork.message}</p>281 <button type="button" onClick={() => dismissStorageFork()}>282 Dismiss283 </button>284 </>285 )}286 <PwaActions title="Install & updates" />287 </div>288 </section>289 );290}