import { useCallback, useEffect, useRef, useState } from "react"; import { DatabaseIcon, DownloadIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"; import { toast } from "../../lib/toast"; import type { SiteDataCollectionResponse, SiteDataDescribeResponse, SiteDataQueryResponse, SiteDataRecordResponse, SiteSqlDescribeResponse, } from "@opengeni/sdk"; import type { OpenGeniBrowserClient } from "@opengeni/sdk/browser"; import { useAppContext } from "../../app-context"; import { Button } from "../../../ui/button"; import { BackLink, DetailMeta } from "../../../ui/detail-page"; import { DestructiveConfirm } from "../ui/destructive-confirm"; import { DropdownMenuItem } from "../../../ui/dropdown-menu"; import { EmptyState } from "../../../ui/empty-state"; import { ListRow, ListRowSkeleton, RowList, type RowListColumn } from "../../../ui/list-row"; import { LogoTile } from "../../../ui/logo-tile"; import { Notice } from "../../../ui/notice"; import { MoreMenu, RowButton } from "../../../ui/page-actions"; import { RelativeTime } from "../../../ui/relative-time"; import { SettingRow, SettingRowGroup } from "../../../ui/setting-row"; import { Switch } from "../../../ui/switch"; import { UsageMeter } from "../../../ui/usage-meter"; import { formatBytes } from "../../lib/format"; export type SiteDataClient = Pick< OpenGeniBrowserClient, "getSiteData" | "querySiteData" | "writeSiteData" | "exportSiteData" > & Partial>; const PAGE_SIZE = 50; const COLLECTION_COLUMNS: RowListColumn[] = [ { id: "records", label: "Records", width: 96, align: "end" }, { id: "size", label: "Size", width: 96, align: "end" }, ]; const RECORD_COLUMNS: RowListColumn[] = [ { id: "updated", label: "Updated", width: 132 }, { id: "size", label: "Size", width: 88, align: "end" }, ]; /** Who can read and write a collection, in the words a Site's manager uses. */ export function siteDataAccessLabel( collection: Pick, ): string { switch (collection.accessMode) { case "shared": return "Everyone can edit"; case "shared_read": return "Everyone can view"; case "per_viewer": return collection.privateRecords ? "Each person's own, hidden from managers" : "Each person sees their own"; case "append_only": return "Submissions"; } } /** A record's one-line name: its key, else its first short text field. */ export function siteDataRecordTitle(record: Pick): string { if (record.key) return record.key; for (const value of Object.values(record.data)) { if (typeof value === "string" && value.trim()) return value.trim().slice(0, 120); } return "Untitled record"; } function countLabel(count: number, noun: string): string { return `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}`; } function percentUsed(used: number, limit: number): number { return limit > 0 ? (used / limit) * 100 : 0; } function forbidden(error: unknown): boolean { return ( typeof error === "object" && error !== null && "status" in error && (error as { status: unknown }).status === 403 ); } /** * The Site page's Data tab: what the Site and its agents saved. Host-owned, * for the people who manage the Site; records are shown as text, never HTML. */ export function SiteData(props: { workspaceId: string; siteId: string; title: string }) { const { client, accessContext } = useAppContext(); return ( ); } export function SiteDataPanel({ client, workspaceId, siteId, title, currentPrincipal, }: { client: SiteDataClient; workspaceId: string; siteId: string; title: string; currentPrincipal: string | null; }) { const [described, setDescribed] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [collectionName, setCollectionName] = useState(null); const request = useRef(null); const load = useCallback(async () => { request.current?.abort(); const controller = new AbortController(); request.current = controller; setBusy(true); setError(null); try { const next = await client.getSiteData(workspaceId, siteId, { signal: controller.signal }); if (!controller.signal.aborted) setDescribed(next); } catch (nextError) { if (!controller.signal.aborted) setError(nextError); } finally { if (!controller.signal.aborted) setBusy(false); } }, [client, workspaceId, siteId]); useEffect(() => { setDescribed(null); setCollectionName(null); void load(); return () => request.current?.abort(); }, [load]); const collection = collectionName && described ? (described.collections.find((candidate) => candidate.name === collectionName) ?? null) : null; if (collection && described) return ( { setCollectionName(null); void load(); }} /> ); return (
{error ? ( void load()}> ) : !described ? ( ) : described.collections.length === 0 && described.usage.fileCount === 0 ? ( } title="No saved data yet." description="Ask Opengeni to add a collection, and the Site can save records and files in it." /> ) : ( <>
{described.collections.map((item) => ( } name="Collection" />} title={item.name} meta={[siteDataAccessLabel(item)]} cells={{ records: item.recordCount.toLocaleString(), size: formatBytes(item.recordBytes), }} indicator="open" onOpen={() => setCollectionName(item.name)} /> ))} )} {!error && described ? ( ) : null}
); } /** What the Site's SQL database holds, in one line. */ export function siteSqlSummary( state: Pick, ): string { if (!state.enabled) return state.usage.storageBytes > 0 ? "Off. Its data is kept." : "Tables and queries for this Site. Ask Opengeni to set them up."; const queries = state.queries.length; return `${formatBytes(state.usage.storageBytes)} of ${formatBytes(state.limits.storageBytesPerSite)} · ${queries.toLocaleString()} ${queries === 1 ? "query" : "queries"}`; } /** * The owner's switch for the Site's optional SQL database. Shown only where * the deployment offers it; agents add the tables and queries. */ function SiteSqlSetting({ client, workspaceId, siteId, }: { client: SiteDataClient; workspaceId: string; siteId: string; }) { const [state, setState] = useState(null); const [pending, setPending] = useState(false); useEffect(() => { if (!client.getSiteSql) return; const controller = new AbortController(); setState(null); client .getSiteSql(workspaceId, siteId, { signal: controller.signal }) .then((next) => { if (!controller.signal.aborted) setState(next); }) .catch(() => undefined); return () => controller.abort(); }, [client, workspaceId, siteId]); const update = client.updateSiteSqlSettings; if (!state?.available || !update) return null; const toggle = async (enabled: boolean) => { setPending(true); try { setState(await update.call(client, workspaceId, siteId, { enabled })); } catch { toast.error( enabled ? "Couldn't turn on the SQL database." : "Couldn't turn off the SQL database.", ); } finally { setPending(false); } }; return ( void toggle(next)} /> } /> ); } function SiteDataCollection({ client, workspaceId, siteId, collection, currentPrincipal, onBack, }: { client: SiteDataClient; workspaceId: string; siteId: string; collection: SiteDataCollectionResponse; currentPrincipal: string | null; onBack: () => void; }) { const [page, setPage] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(false); const [open, setOpen] = useState(null); const [exporting, setExporting] = useState(false); const request = useRef(null); const load = useCallback( async (cursor?: string) => { request.current?.abort(); const controller = new AbortController(); request.current = controller; setBusy(true); setError(false); try { const next = await client.querySiteData( workspaceId, siteId, { collection: collection.name, limit: PAGE_SIZE, ...(cursor ? { cursor } : { count: true }), }, { signal: controller.signal }, ); if (controller.signal.aborted) return; setPage((previous) => cursor && previous ? { ...next, ...(previous.count !== undefined ? { count: previous.count } : {}), records: [...previous.records, ...next.records], } : next, ); } catch { if (!controller.signal.aborted) setError(true); } finally { if (!controller.signal.aborted) setBusy(false); } }, [client, workspaceId, siteId, collection.name], ); useEffect(() => { void load(); return () => request.current?.abort(); }, [load]); const exportAs = async (format: "json" | "csv") => { setExporting(true); try { const exported = await client.exportSiteData(workspaceId, siteId, collection.name, { format, }); const url = URL.createObjectURL( new Blob([exported.content], { type: format === "csv" ? "text/csv" : "application/json", }), ); const link = document.createElement("a"); link.href = url; link.download = `${collection.name}.${format}`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 0); if (exported.truncated) toast.warning("Exported the first 10,000 records", { description: "Ask Opengeni to export the rest in parts.", }); } catch (nextError) { toast.error("Couldn't export records", { description: nextError instanceof Error ? nextError.message : String(nextError), }); } finally { setExporting(false); } }; if (open) return ( setOpen(null)} onDeleted={() => { setOpen(null); void load(); }} /> ); const total = page?.count ?? collection.recordCount; return (

{collection.name}

{siteDataAccessLabel(collection)} {countLabel(total, "record")} {formatBytes(collection.recordBytes)}
void exportAs("csv")}> Export as CSV void exportAs("json")}> Export as JSON
{error ? ( void load()}> ) : !page ? ( ) : page.records.length === 0 ? ( ) : ( {page.records.map((record) => ( , size: formatBytes(record.sizeBytes), }} indicator="open" onOpen={() => setOpen(record)} /> ))} )} {page?.nextCursor && !error ? ( ) : null}
); } function SiteDataRecordView({ client, workspaceId, siteId, collection, record, currentPrincipal, onBack, onDeleted, }: { client: SiteDataClient; workspaceId: string; siteId: string; collection: SiteDataCollectionResponse; record: SiteDataRecordResponse; currentPrincipal: string | null; onBack: () => void; onDeleted: () => void; }) { const [confirming, setConfirming] = useState(false); const name = siteDataRecordTitle(record); const remove = async () => { await client.writeSiteData(workspaceId, siteId, { operations: [ { op: "delete", collection: collection.name, id: record.id, expectedVersion: record.version, }, ], }); toast.success("Record deleted"); onDeleted(); }; return (

{name}

{`Version ${record.version}`} {formatBytes(record.sizeBytes)} {currentPrincipal && record.owner === currentPrincipal ? "Added by you" : null}
setConfirming(true)}>
        {JSON.stringify(record.data, null, 2)}
      
); }