carbon-tokenization / frontend /src /components /SyncIndicator.tsx
tfrere's picture
tfrere HF Staff
feat(storage): first-class data - no silent failures in the persistence pipeline
7a42df5
import { useEffect, useRef, useState } from "react";
import type { Editor as TiptapEditor } from "@tiptap/core";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import { Cloud, CloudOff, AlertTriangle, Loader2 } from "lucide-react";
import { Tooltip } from "./Tooltip";
/**
* Server-side persistence pipeline state, as returned by
* `GET /api/storage/status`. Mirrors the shape of `StorageStatus`
* in `backend/src/hf-storage.ts` - if you add a field there, add
* it here too.
*/
interface StorageStatus {
enabled: boolean;
datasetId: string;
datasetReady: boolean;
lastLocalSaveAt: number | null;
lastCloudPushAt: number | null;
pendingPush: boolean;
lastError: {
stage: "dataset-create" | "local-save" | "cloud-push";
message: string;
statusCode?: number;
at: number;
docName?: string;
} | null;
}
/**
* What the user sees in the top bar, derived from BOTH the WS
* connection state AND the server-side persistence pipeline.
*
* Severity ordering (worst first): error > offline > pending > saved.
* The displayed status is always the worst applicable signal, so
* a green "Saved" never wins over a red "Sync failed".
*/
type DisplayStatus =
| "saved" // WS connected + dataset ready + no error + recent push
| "pending" // edit in flight or push timer armed
| "offline" // WS disconnected (network or container restart)
| "error"; // backend reports lastError in the pipeline
interface Props {
editorInstance: TiptapEditor | null;
providerRef: { current: HocuspocusProvider | null };
}
const POLL_MS = 5_000;
export function SyncIndicator({ editorInstance: _editorInstance, providerRef }: Props) {
const [wsConnected, setWsConnected] = useState(false);
const [hasLocalEdit, setHasLocalEdit] = useState(false);
const [serverStatus, setServerStatus] = useState<StorageStatus | null>(null);
const editTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// ---- WS layer: connection + edit activity ------------------------------
// Same lazy-provider polling pattern as the previous version: the
// provider is created by <Editor> AFTER this component mounts, so a
// useEffect([providerRef]) alone would never re-fire when it lands.
useEffect(() => {
let pollId: ReturnType<typeof setInterval> | null = null;
const attach = (p: HocuspocusProvider) => {
const onConnect = () => setWsConnected(true);
const onDisconnect = () => setWsConnected(false);
const onSynced = () => setWsConnected(true);
p.on("connect", onConnect);
p.on("disconnect", onDisconnect);
p.on("synced", onSynced);
// Seed + 1s reconcile loop (some HF proxies eat the first
// `connect` event; without this we'd stay "Offline" forever).
const seed = () => {
const ws = (p as any).configuration?.websocketProvider;
setWsConnected(ws?.status === "connected");
};
seed();
const reconcile = setInterval(seed, 1000);
// Listen at the Yjs layer so we see EVERY change (TipTap's
// own `update` event misses hero/settings/citation edits
// because those bypass prosemirror).
const ydoc = (p as any).document as
| { on: Function; off: Function }
| undefined;
const onYUpdate = (_u: Uint8Array, origin: unknown) => {
if (origin === p) return; // remote update, not ours
setHasLocalEdit(true);
if (editTimerRef.current) clearTimeout(editTimerRef.current);
// Local edit "settles" 1.5s after the last keystroke; this
// is also roughly when the backend's `debouncedSave` fires
// (2s), so the indicator briefly flashes pending then
// recovers to saved/error based on the next poll.
editTimerRef.current = setTimeout(() => setHasLocalEdit(false), 1500);
};
ydoc?.on?.("update", onYUpdate);
return () => {
p.off("connect", onConnect);
p.off("disconnect", onDisconnect);
p.off("synced", onSynced);
ydoc?.off?.("update", onYUpdate);
clearInterval(reconcile);
};
};
let cleanup: (() => void) | null = null;
if (providerRef.current) {
cleanup = attach(providerRef.current);
} else {
pollId = setInterval(() => {
const p = providerRef.current;
if (!p) return;
if (pollId) {
clearInterval(pollId);
pollId = null;
}
cleanup = attach(p);
}, 100);
}
return () => {
if (pollId) clearInterval(pollId);
if (cleanup) cleanup();
};
}, [providerRef]);
// ---- Server pipeline polling -------------------------------------------
// Cheap GET every 5s. The backend tracker updates in-process on
// every save/push/error so the worst-case latency for surfacing
// a problem is one poll interval. We don't use SSE/WS for this
// because the data is tiny, the polling interval is generous,
// and adding another long-lived connection is more failure modes
// than it's worth.
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
try {
const res = await fetch("/api/storage/status", {
credentials: "include",
});
if (cancelled) return;
if (res.ok) {
const data = (await res.json()) as StorageStatus;
setServerStatus(data);
} else if (res.status === 403) {
// Viewer (not an editor) - storage status isn't relevant
// to them. Stop polling.
return;
}
} catch {
// Network blip - keep trying. The WS disconnection will
// dominate the UI anyway in that case.
}
if (!cancelled) {
timer = setTimeout(poll, POLL_MS);
}
};
poll();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, []);
// ---- Derive the displayed status ---------------------------------------
// Worst-applicable wins (see DisplayStatus jsdoc).
const status: DisplayStatus = (() => {
if (serverStatus?.lastError) return "error";
if (!wsConnected) return "offline";
if (hasLocalEdit || serverStatus?.pendingPush) return "pending";
return "saved";
})();
// ---- beforeunload guard ------------------------------------------------
// If there's an unsynced local edit OR a pending push OR a known
// sync error, browsers should pop the standard "Leave site?"
// confirmation. The exact message is ignored by modern browsers
// (Chrome/Safari/Firefox show their own generic copy) but
// setting `returnValue` is what triggers the prompt.
useEffect(() => {
const needsGuard = status === "pending" || status === "error" || status === "offline";
if (!needsGuard) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Legacy browsers (and TS types still hold this) want a string.
e.returnValue = "";
return "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [status]);
// ---- Render ------------------------------------------------------------
const { icon, label, tooltip } = renderState(status, serverStatus);
return (
<Tooltip title={tooltip}>
<span
className={`sync-indicator sync-indicator--${status}`}
role="status"
aria-live="polite"
>
{icon}
<span className="sync-indicator__label">{label}</span>
</span>
</Tooltip>
);
}
function renderState(status: DisplayStatus, server: StorageStatus | null) {
switch (status) {
case "error": {
const err = server?.lastError;
const stageLabel: Record<string, string> = {
"dataset-create": "Cloud storage setup failed",
"local-save": "Local save failed",
"cloud-push": "Cloud sync failed",
};
const label = err ? stageLabel[err.stage] ?? "Storage error" : "Storage error";
const hint = err?.statusCode === 403
? " - your OAuth grant may be missing the `manage-repos` scope. Sign out and back in."
: "";
const tooltip = err
? `${label}: ${err.message}${hint}`
: "Storage error";
return {
icon: <AlertTriangle size={14} />,
label,
tooltip,
};
}
case "offline":
return {
icon: <CloudOff size={14} />,
label: "Offline",
tooltip: "Disconnected - reconnecting...",
};
case "pending":
return {
icon: <Loader2 size={14} className="spin" />,
label: "Saving...",
tooltip: server?.datasetReady === false
? "Saving locally - cloud sync starts after first successful dataset creation"
: "Saving to cloud...",
};
case "saved":
default: {
const last = server?.lastCloudPushAt;
const tooltip = last
? `All changes saved · last cloud sync ${formatRelative(last)}`
: server?.datasetReady
? "All changes saved"
: "Saved locally - cloud sync will start on first change";
return {
icon: <Cloud size={14} />,
label: "Saved",
tooltip,
};
}
}
}
function formatRelative(ts: number): string {
const diff = Date.now() - ts;
if (diff < 5_000) return "just now";
if (diff < 60_000) return `${Math.round(diff / 1000)}s ago`;
if (diff < 3_600_000) return `${Math.round(diff / 60_000)}min ago`;
return new Date(ts).toLocaleTimeString();
}