Files
template-app/modules/account/ui/account-analytics.tsx
T

198 lines
7.0 KiB
TypeScript

"use client"
// "Your analytics data" (ADR-049): what the platform's own stores keep about the person's visits --
// session replay, errors, access logs, traces, logs and analytics -- erased or exported by one
// platform job. Both buttons call this app's server (account.analytics.*), which calls the platform
// with the deployment token; the browser holds only the ticket of the job it started, in memory.
// The erasure asks for a confirmation first, because it cannot be undone. While a job is open the
// panel polls its status; a completed export offers the download.
import { DataTable, useTransport } from "@conusai-kit/ui"
import { type ReactElement, useCallback, useEffect, useId, useState } from "react"
import type { AnalyticsView } from "../contract/procedures.js"
export interface AccountAnalyticsLabels {
readonly heading: string
readonly body: string
readonly erase: string
readonly exportSubmit: string
readonly busy: string
readonly confirmErase: string
readonly confirm: string
readonly cancel: string
readonly download: string
readonly filename: string
/** One per job status: pending, running, completed, failed, expired. */
readonly status: Readonly<Record<AnalyticsView["status"], string>>
readonly store: string
readonly storeStatus: string
readonly deleted: string
readonly anonymized: string
readonly exported: string
/** A readable name per store key; a key without one is shown as it is. */
readonly storeNames: Readonly<Record<string, string>>
/** The app's `app.failed` message, with `{detail}` still in it. */
readonly failed: string
}
/** How often an open job is asked about. */
export const ANALYTICS_POLL_MS = 5_000
type Busy = "erase" | "export" | "download" | null
function save(document: Record<string, unknown>, filename: string, exportedAt: string): void {
const href = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" }))
const link = window.document.createElement("a")
link.href = href
link.download = `${filename}-${exportedAt.slice(0, 10)}.json`
window.document.body.append(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(href), 0)
}
const isOpen = (job: AnalyticsView | null) => job !== null && (job.status === "pending" || job.status === "running")
export function AccountAnalyticsPanel({
labels,
pollMs = ANALYTICS_POLL_MS,
}: {
labels: AccountAnalyticsLabels
pollMs?: number
}): ReactElement {
const { call, describe } = useTransport()
const headingId = useId()
const [jobs, setJobs] = useState<{ erase: AnalyticsView | null; export: AnalyticsView | null }>({
erase: null,
export: null,
})
const [busy, setBusy] = useState<Busy>(null)
const [confirming, setConfirming] = useState(false)
const [failure, setFailure] = useState<string | null>(null)
const remember = useCallback((job: AnalyticsView) => setJobs((current) => ({ ...current, [job.kind]: job })), [])
const start = async (kind: AnalyticsView["kind"]): Promise<void> => {
setBusy(kind)
setFailure(null)
setConfirming(false)
try {
remember((await call("account.analytics.request", { kind })) as AnalyticsView)
} catch (error) {
setFailure(describe(error))
} finally {
setBusy(null)
}
}
const download = async (job: AnalyticsView): Promise<void> => {
setBusy("download")
setFailure(null)
try {
const answer = (await call("account.analytics.export", { ticket: job.ticket })) as {
exportedAt: string
document: Record<string, unknown>
}
save(answer.document, labels.filename, answer.exportedAt)
} catch (error) {
setFailure(describe(error))
} finally {
setBusy(null)
}
}
// Follow every open job until it settles.
useEffect(() => {
const open = [jobs.erase, jobs.export].filter(isOpen) as AnalyticsView[]
if (open.length === 0) return
const timer = setTimeout(() => {
for (const job of open) {
call("account.analytics.status", { ticket: job.ticket })
.then((next) => remember(next as AnalyticsView))
.catch((error: unknown) => setFailure(describe(error)))
}
}, pollMs)
return () => clearTimeout(timer)
}, [jobs, call, describe, remember, pollMs])
const rows = (job: AnalyticsView) =>
Object.entries(job.stores).map(([store, outcome]) => ({
id: store,
store: labels.storeNames[store] ?? store,
status: outcome.status,
deleted: outcome.deleted,
anonymized: outcome.anonymized,
exported: outcome.exported,
}))
const jobView = (job: AnalyticsView | null) =>
job === null ? null : (
<div className="app-stack" data-kind={job.kind}>
<p role="status">{labels.status[job.status]}</p>
{Object.keys(job.stores).length > 0 ? (
<DataTable
columns={[
{ id: "store", header: labels.store },
{ id: "status", header: labels.storeStatus },
...(job.kind === "erase"
? [
{ id: "deleted" as const, header: labels.deleted },
{ id: "anonymized" as const, header: labels.anonymized },
]
: [{ id: "exported" as const, header: labels.exported }]),
]}
rows={rows(job)}
/>
) : null}
{job.kind === "export" && job.status === "completed" ? (
<button type="button" onClick={() => download(job)} disabled={busy !== null} aria-busy={busy === "download"}>
{busy === "download" ? labels.busy : labels.download}
</button>
) : null}
</div>
)
return (
<section aria-labelledby={headingId}>
<h2 id={headingId}>{labels.heading}</h2>
<p>{labels.body}</p>
<div className="app-actions">
<button
type="button"
onClick={() => start("export")}
disabled={busy !== null || isOpen(jobs.export)}
aria-busy={busy === "export"}
>
{busy === "export" ? labels.busy : labels.exportSubmit}
</button>
<button
type="button"
className="app-danger"
onClick={() => setConfirming(true)}
disabled={busy !== null || confirming || isOpen(jobs.erase)}
>
{busy === "erase" ? labels.busy : labels.erase}
</button>
</div>
{confirming ? (
<div role="alertdialog" aria-labelledby={`${headingId}-confirm`} className="app-confirm">
<p id={`${headingId}-confirm`}>{labels.confirmErase}</p>
<button type="button" className="app-danger" onClick={() => start("erase")}>
{labels.confirm}
</button>
<button type="button" onClick={() => setConfirming(false)}>
{labels.cancel}
</button>
</div>
) : null}
{jobView(jobs.export)}
{jobView(jobs.erase)}
{failure === null ? null : (
<p role="alert" className="app-error">
{labels.failed.replace("{detail}", failure)}
</p>
)}
</section>
)
}