This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user