198 lines
7.0 KiB
TypeScript
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>
|
|
)
|
|
}
|