feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file PrivacySettings.tsx
|
||||
* @description Settings → Data → Privacy: the two opt-in ingest controls of
|
||||
* `server/lib/privacy.js` — redact secrets (API keys, tokens, bearer headers,
|
||||
* private keys, `password=…`) from the payloads and summaries AgentHub stores,
|
||||
* and a list of folders whose hooks are dropped whole (one absolute path per
|
||||
* line; a folder and everything under it). Both persist through
|
||||
* `PUT /api/settings/privacy`; one the environment set is shown read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ShieldCheck } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { PrivacySettings as PrivacyState } from "../../lib/api";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
const lines = (text: string) =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export function PrivacySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [state, setState] = useState<PrivacyState | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const apply = (s: PrivacyState) => {
|
||||
setState(s);
|
||||
setDraft(s.ignore_cwd.join("\n"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.privacy
|
||||
.get()
|
||||
.then(apply)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
async function save(patch: { redact_secrets?: boolean; ignore_cwd?: string[] }) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
apply(await api.settings.privacy.save(patch));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const listChanged = !!state && lines(draft).join("\n") !== state.ignore_cwd.join("\n");
|
||||
|
||||
return (
|
||||
<div id="privacy" className="scroll-mt-24">
|
||||
<SettingsSection
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-fg-muted" />
|
||||
{t("privacy.title")}
|
||||
</span>
|
||||
}
|
||||
description={t("privacy.description")}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={t("privacy.redact")}
|
||||
description={t("privacy.redactHint")}
|
||||
checked={!!state?.redact_secrets}
|
||||
disabled={!state || saving || state.env_owned.redact_secrets}
|
||||
onChange={(v) => void save({ redact_secrets: v })}
|
||||
/>
|
||||
<div className="py-3 space-y-2">
|
||||
<label htmlFor="privacy-ignore" className="block text-sm text-fg-secondary">
|
||||
{t("privacy.ignore")}
|
||||
</label>
|
||||
<p className="text-xs text-fg-muted">{t("privacy.ignoreHint")}</p>
|
||||
<textarea
|
||||
id="privacy-ignore"
|
||||
className="input w-full font-mono text-xs min-h-[72px]"
|
||||
placeholder={"/home/you/clients/secret-project\n~/private"}
|
||||
value={draft}
|
||||
disabled={!state || state.env_owned.ignore_cwd}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving || !listChanged}
|
||||
onClick={() => void save({ ignore_cwd: lines(draft) })}
|
||||
>
|
||||
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||
</button>
|
||||
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||
{(state?.env_owned.redact_secrets || state?.env_owned.ignore_cwd) && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{t("privacy.envOwned")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @file PushIngestSettings.tsx
|
||||
* @description Settings → Data → Push ingest: the bearer token that opens
|
||||
* `POST /api/hooks/ingest-batch`, for a machine this dashboard cannot reach
|
||||
* over SSH, so the machine pushes its hooks instead. Off until a token is
|
||||
* created; the token is shown once, right after it is made (only its hash is
|
||||
* stored), and can be rotated or revoked. Also shows a forwarder script for
|
||||
* the other machine — its token in a header file, never on a command line.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, Copy, KeyRound, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { PushTokenStatus } from "../../lib/api";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
const copyText = (text: string) => navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
|
||||
/** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */
|
||||
function forwarder(origin: string) {
|
||||
return `#!/bin/sh
|
||||
# ~/.claude/agenthub-push.sh <HookEvent> — a Claude Code hook command:
|
||||
# "command": "sh ~/.claude/agenthub-push.sh PreToolUse"
|
||||
# ~/.claude/agenthub-push.header (chmod 600) holds one line:
|
||||
# Authorization: Bearer <token>
|
||||
jq -c --arg t "$1" --arg s "$(hostname -s)" \\
|
||||
'{source: $s, events: [{hook_type: $t, data: .}]}' |
|
||||
curl -s -m 5 -o /dev/null -X POST "${origin}/api/hooks/ingest-batch" \\
|
||||
-H "@$HOME/.claude/agenthub-push.header" \\
|
||||
-H "Content-Type: application/json" --data-binary @-
|
||||
exit 0`;
|
||||
}
|
||||
|
||||
export function PushIngestSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<PushTokenStatus | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Rotating a live token or revoking one asks first.
|
||||
const [confirm, setConfirm] = useState<"rotate" | "revoke" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.pushToken
|
||||
.get()
|
||||
.then(setStatus)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
async function run(fn: () => Promise<void>) {
|
||||
setConfirm(null);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const rotate = () =>
|
||||
run(async () => {
|
||||
const r = await api.settings.pushToken.rotate();
|
||||
setToken(r.token);
|
||||
setStatus({ enabled: r.enabled, env_owned: r.env_owned });
|
||||
});
|
||||
const revoke = () =>
|
||||
run(async () => {
|
||||
setStatus(await api.settings.pushToken.revoke());
|
||||
setToken(null);
|
||||
});
|
||||
|
||||
const script = forwarder(window.location.origin);
|
||||
|
||||
return (
|
||||
<div id="push-ingest" className="scroll-mt-24">
|
||||
<SettingsSection
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 text-fg-muted" />
|
||||
{t("pushIngest.title")}
|
||||
</span>
|
||||
}
|
||||
description={t("pushIngest.description")}
|
||||
>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`text-xs ${status?.enabled ? "text-status-success" : "text-fg-muted"}`}
|
||||
>
|
||||
{status?.enabled ? t("pushIngest.on") : t("pushIngest.off")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-xs disabled:opacity-40"
|
||||
disabled={busy || !status || status.env_owned}
|
||||
onClick={() => (status?.enabled ? setConfirm("rotate") : void rotate())}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{status?.enabled ? t("pushIngest.rotate") : t("pushIngest.create")}
|
||||
</button>
|
||||
{status?.enabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
disabled={busy || status.env_owned}
|
||||
onClick={() => setConfirm("revoke")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t("pushIngest.revoke")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{token && (
|
||||
<div className="rounded-lg border border-status-warning/25 bg-status-warning/10 p-3 space-y-2">
|
||||
<p className="text-xs text-status-warning">{t("pushIngest.shownOnce")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 min-w-0 break-all text-xs text-fg-primary">{token}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
title={t("pushIngest.copy")}
|
||||
onClick={() => void copyText(token)}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status?.env_owned && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{t("pushIngest.envOwned")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-fg-muted">{t("pushIngest.reachHint")}</p>
|
||||
<div className="relative">
|
||||
<pre className="text-[11px] font-mono bg-surface-1 border border-border rounded-lg p-3 overflow-x-auto text-fg-secondary">
|
||||
{script}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs absolute top-1.5 right-1.5"
|
||||
title={t("pushIngest.copy")}
|
||||
onClick={() => void copyText(script)}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{t("pushIngest.limits")}</p>
|
||||
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||
</div>
|
||||
<ConfirmModal
|
||||
open={confirm !== null}
|
||||
title={t(confirm === "revoke" ? "pushIngest.revokeConfirm" : "pushIngest.rotateConfirm")}
|
||||
confirmLabel={t(confirm === "revoke" ? "pushIngest.revoke" : "pushIngest.rotate")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={confirm === "revoke"}
|
||||
busy={busy}
|
||||
onConfirm={() => void (confirm === "revoke" ? revoke() : rotate())}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* @file ReportSettings.tsx
|
||||
* @description Settings → Reports: scheduled report definitions (`/api/reports`)
|
||||
* — create/edit (name, window, daily/weekly + weekday, local hour, HTML/JSON),
|
||||
* enable switch, Run now, a confirmed Delete, and each report's run history
|
||||
* with download links. Every row action is also on a right-click menu. The
|
||||
* browser's timezone offset is saved with the definition, so its hour is the
|
||||
* user's local hour.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, History, Pencil, Play, Plus, Trash2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import type { ReportDefinition, ReportInput, ReportRun } from "../../lib/types";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { Select } from "../Select";
|
||||
import { Switch } from "../Switch";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
const EMPTY: ReportInput = {
|
||||
name: "",
|
||||
window_days: 7,
|
||||
frequency: "weekly",
|
||||
hour: 8,
|
||||
weekday: 1,
|
||||
tz_offset: 0,
|
||||
formats: ["html"],
|
||||
enabled: true,
|
||||
};
|
||||
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
export function ReportSettings() {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const [items, setItems] = useState<ReportDefinition[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** The form: a new report (id null) or the one being edited. */
|
||||
const [form, setForm] = useState<{ id: number | null; value: ReportInput } | null>(null);
|
||||
const [deleting, setDeleting] = useState<ReportDefinition | null>(null);
|
||||
const [busy, setBusy] = useState<number | "form" | null>(null);
|
||||
const [history, setHistory] = useState<{ id: number; runs: ReportRun[] } | null>(null);
|
||||
const ctx = useContextMenu<ReportDefinition>();
|
||||
|
||||
const weekday = (d: number) =>
|
||||
new Intl.DateTimeFormat(i18n.resolvedLanguage ?? i18n.language, { weekday: "long" }).format(
|
||||
new Date(Date.UTC(2026, 0, 4 + d))
|
||||
);
|
||||
const fail = (err: unknown) => setError(err instanceof Error ? err.message : String(err));
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.reports
|
||||
.list()
|
||||
.then((r) => setItems(r.reports))
|
||||
.catch(fail);
|
||||
}, []);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const showHistory = async (id: number) => {
|
||||
try {
|
||||
const r = await api.reports.runs(id);
|
||||
setHistory({ id, runs: r.runs });
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return;
|
||||
setBusy("form");
|
||||
const value = { ...form.value, tz_offset: new Date().getTimezoneOffset() };
|
||||
try {
|
||||
if (form.id === null) await api.reports.create(value);
|
||||
else await api.reports.update(form.id, value);
|
||||
setForm(null);
|
||||
setError(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = async (r: ReportDefinition, enabled: boolean) => {
|
||||
try {
|
||||
await api.reports.update(r.id, { enabled });
|
||||
load();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
};
|
||||
|
||||
const runNow = async (r: ReportDefinition) => {
|
||||
setBusy(r.id);
|
||||
try {
|
||||
const { run } = await api.reports.run(r.id);
|
||||
if (run.status === "error") setError(run.error);
|
||||
load();
|
||||
if (history?.id === r.id) await showHistory(r.id);
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
await api.reports.remove(deleting.id);
|
||||
if (history?.id === deleting.id) setHistory(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const edit = (r: ReportDefinition) =>
|
||||
setForm({
|
||||
id: r.id,
|
||||
value: {
|
||||
name: r.name,
|
||||
window_days: r.window_days,
|
||||
frequency: r.frequency,
|
||||
hour: r.hour,
|
||||
weekday: r.weekday,
|
||||
tz_offset: r.tz_offset,
|
||||
formats: r.formats,
|
||||
enabled: r.enabled,
|
||||
},
|
||||
});
|
||||
const pick = (fn: (r: ReportDefinition) => void) => () => {
|
||||
const r = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (r) fn(r);
|
||||
};
|
||||
const schedule = (r: ReportInput) =>
|
||||
[
|
||||
r.frequency === "daily"
|
||||
? t("reports.everyDay", { time: `${pad(r.hour)}:00` })
|
||||
: t("reports.everyWeek", { day: weekday(r.weekday), time: `${pad(r.hour)}:00` }),
|
||||
t("reports.window", { count: r.window_days }),
|
||||
r.formats.map((f) => f.toUpperCase()).join(", "),
|
||||
].join(" · ");
|
||||
|
||||
const set = (patch: Partial<ReportInput>) =>
|
||||
setForm((f) => (f ? { ...f, value: { ...f.value, ...patch } } : f));
|
||||
const toggleFormat = (f: "html" | "json", on: boolean) =>
|
||||
set({
|
||||
formats: on
|
||||
? [...(form?.value.formats ?? []), f]
|
||||
: (form?.value.formats ?? []).filter((x) => x !== f),
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("reports.title")} description={t("reports.description")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{form ? (
|
||||
<div
|
||||
className="my-2 space-y-3 rounded-md border border-border p-3"
|
||||
data-testid="report-form"
|
||||
>
|
||||
<label className="block text-xs text-fg-muted">
|
||||
{t("reports.name")}
|
||||
<input
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={form.value.name}
|
||||
maxLength={200}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-xs text-fg-muted">
|
||||
{t("reports.windowDays")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={366}
|
||||
className="input mt-1 block w-24 text-sm"
|
||||
value={form.value.window_days}
|
||||
onChange={(e) => set({ window_days: Math.trunc(Number(e.target.value)) })}
|
||||
/>
|
||||
</label>
|
||||
<div className="w-36">
|
||||
<Select
|
||||
value={form.value.frequency}
|
||||
onChange={(v) => set({ frequency: v })}
|
||||
label={t("reports.frequency")}
|
||||
options={[
|
||||
{ value: "daily", label: t("reports.daily") },
|
||||
{ value: "weekly", label: t("reports.weekly") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{form.value.frequency === "weekly" && (
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={String(form.value.weekday)}
|
||||
onChange={(v) => set({ weekday: Number(v) })}
|
||||
label={t("reports.weekday")}
|
||||
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({
|
||||
value: String(d),
|
||||
label: weekday(d),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-28">
|
||||
<Select
|
||||
value={String(form.value.hour)}
|
||||
onChange={(v) => set({ hour: Number(v) })}
|
||||
label={t("reports.hour")}
|
||||
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Checkbox
|
||||
checked={form.value.formats.includes("html")}
|
||||
onChange={(v) => toggleFormat("html", v)}
|
||||
label="HTML"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={form.value.formats.includes("json")}
|
||||
onChange={(v) => toggleFormat("json", v)}
|
||||
label="JSON"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{t("reports.windowHint")}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-xs"
|
||||
disabled={
|
||||
busy === "form" || !form.value.name.trim() || form.value.formats.length === 0
|
||||
}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("reports.save")}
|
||||
</button>
|
||||
<button type="button" className="btn-ghost text-xs" onClick={() => setForm(null)}>
|
||||
{t("reports.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost my-2 text-xs"
|
||||
onClick={() => setForm({ id: null, value: { ...EMPTY, name: t("reports.defaultName") } })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("reports.add")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("reports.empty")}</p>}
|
||||
{items && items.length > 0 && (
|
||||
<ul className="divide-y divide-border/60" data-testid="report-list">
|
||||
{items.map((r) => (
|
||||
<li key={r.id} onContextMenu={(e) => ctx.open(e, r)} className="py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-fg-secondary">{r.name}</p>
|
||||
<p className="text-[11px] text-fg-muted">{schedule(r)}</p>
|
||||
{r.last_run && (
|
||||
<p
|
||||
className={`text-[11px] ${
|
||||
r.last_run.status === "error" ? "text-status-danger" : "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t("reports.lastRun", { at: formatDateTime(r.last_run.created_at) })}
|
||||
{r.last_run.status === "error" && ` · ${r.last_run.error ?? ""}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={r.enabled}
|
||||
onChange={(v) => void toggle(r, v)}
|
||||
label={t("reports.enabled")}
|
||||
title={t("reports.enabled")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy === r.id}
|
||||
onClick={() => void runNow(r)}
|
||||
title={t("reports.runNow")}
|
||||
aria-label={t("reports.runNow")}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => (history?.id === r.id ? setHistory(null) : void showHistory(r.id))}
|
||||
title={t("reports.history")}
|
||||
aria-label={t("reports.history")}
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => edit(r)}
|
||||
title={t("reports.edit")}
|
||||
aria-label={t("reports.edit")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs text-status-danger"
|
||||
onClick={() => setDeleting(r)}
|
||||
title={t("reports.delete")}
|
||||
aria-label={t("reports.delete")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{history?.id === r.id && (
|
||||
<ul
|
||||
className="mt-2 space-y-1 border-l border-border pl-3"
|
||||
data-testid="report-runs"
|
||||
>
|
||||
{history.runs.length === 0 && (
|
||||
<li className="text-[11px] text-fg-muted">{t("reports.noRuns")}</li>
|
||||
)}
|
||||
{history.runs.map((run) => (
|
||||
<li key={run.id} className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="text-fg-secondary">{formatDateTime(run.created_at)}</span>
|
||||
<span className="text-fg-muted">
|
||||
{t(`reports.trigger.${run.trigger}`)} · {run.window_from} → {run.window_to}
|
||||
</span>
|
||||
{run.status === "error" ? (
|
||||
<span className="text-status-danger">{run.error}</span>
|
||||
) : (
|
||||
run.files.map((f) => (
|
||||
<a
|
||||
key={f}
|
||||
href={api.reports.fileUrl(r.id, run.id, f)}
|
||||
download
|
||||
className="inline-flex items-center gap-0.5 text-accent hover:underline"
|
||||
title={t("reports.download", { format: f.toUpperCase() })}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{f.toUpperCase()}
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="report-menu">
|
||||
<MenuItem onSelect={pick((r) => void runNow(r))}>{t("reports.runNow")}</MenuItem>
|
||||
<MenuItem onSelect={pick((r) => void showHistory(r.id))}>{t("reports.history")}</MenuItem>
|
||||
<MenuItem onSelect={pick(edit)}>{t("reports.edit")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick(setDeleting)}>
|
||||
{t("reports.delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={deleting !== null}
|
||||
title={t("reports.deleteTitle")}
|
||||
message={t("reports.deleteBody")}
|
||||
confirmLabel={t("reports.delete")}
|
||||
cancelLabel={t("reports.cancel")}
|
||||
onConfirm={() => void remove()}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file SnapshotSettings.tsx
|
||||
* @description Settings → Snapshots: every frozen session (`GET
|
||||
* /api/snapshots`), each with Open, Copy link and a confirmed Delete — the
|
||||
* buttons and the same actions on a right-click menu. Snapshots survive Clear
|
||||
* Data, so this is the one place they go away.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import type { SessionSnapshotInfo } from "../../lib/types";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { CopyButton } from "../event-views/primitives";
|
||||
import { snapshotUrl } from "../SnapshotButton";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function SnapshotSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [items, setItems] = useState<SessionSnapshotInfo[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<SessionSnapshotInfo | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ctx = useContextMenu<SessionSnapshotInfo>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.snapshots
|
||||
.list()
|
||||
.then((r) => setItems(r.snapshots))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, []);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const remove = async () => {
|
||||
if (!deleting) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.snapshots.remove(deleting.id);
|
||||
setItems((xs) => xs?.filter((x) => x.id !== deleting.id) ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
const pick = (fn: (s: SessionSnapshotInfo) => void) => () => {
|
||||
const s = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (s) fn(s);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("snapshots.title")} description={t("snapshots.description")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("snapshots.empty")}</p>}
|
||||
{items && items.length > 0 && (
|
||||
<ul className="divide-y divide-border/60" data-testid="snapshot-list">
|
||||
{items.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
onContextMenu={(e) => ctx.open(e, s)}
|
||||
className="flex items-center gap-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-fg-secondary">
|
||||
{s.session_name || s.session_id.slice(0, 8)}
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-[11px] text-fg-muted">
|
||||
<span>{formatDateTime(s.created_at)}</span>
|
||||
<span>· {t("snapshots.events", { n: s.event_count ?? 0 })}</span>
|
||||
{s.redacted && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5"
|
||||
title={t("snapshots.redacted")}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
{t("snapshots.redacted")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/snapshot/${s.token}`}
|
||||
className="btn-ghost text-xs"
|
||||
title={t("snapshots.open")}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<CopyButton text={snapshotUrl(s.token)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleting(s)}
|
||||
className="btn-ghost text-xs text-status-danger"
|
||||
title={t("snapshots.delete")}
|
||||
aria-label={t("snapshots.delete")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="snapshot-menu">
|
||||
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
|
||||
{t("snapshots.open")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pick((s) => void navigator.clipboard?.writeText(snapshotUrl(s.token)))}
|
||||
>
|
||||
{t("snapshots.copyLink")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick(setDeleting)}>
|
||||
{t("snapshots.delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={deleting !== null}
|
||||
title={t("snapshots.deleteTitle")}
|
||||
message={t("snapshots.deleteBody")}
|
||||
confirmLabel={t("snapshots.delete")}
|
||||
cancelLabel={t("snapshots.cancel")}
|
||||
busy={busy}
|
||||
onConfirm={() => void remove()}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file PushIngestSettings.test.tsx
|
||||
* @description Settings → Data → Push ingest and Privacy: a created token is
|
||||
* shown once (a reload shows only that push is on), and the ignored-folder
|
||||
* list is saved one path per line with blank lines dropped.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
|
||||
const { pushToken, privacy } = vi.hoisted(() => ({
|
||||
pushToken: { get: vi.fn(), rotate: vi.fn(), revoke: vi.fn() },
|
||||
privacy: { get: vi.fn(), save: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({ api: { settings: { pushToken, privacy } } }));
|
||||
|
||||
import { PushIngestSettings } from "../PushIngestSettings";
|
||||
import { PrivacySettings } from "../PrivacySettings";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("PushIngestSettings", () => {
|
||||
it("shows a new token once, and never on a fresh load", async () => {
|
||||
const user = userEvent.setup();
|
||||
pushToken.get.mockResolvedValue({ enabled: false, env_owned: false });
|
||||
pushToken.rotate.mockResolvedValue({ token: "ahp_secret", enabled: true, env_owned: false });
|
||||
const view = render(<PushIngestSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: /Create token/ }));
|
||||
expect(await screen.findByText("ahp_secret")).toBeInTheDocument();
|
||||
view.unmount();
|
||||
|
||||
pushToken.get.mockResolvedValue({ enabled: true, env_owned: false });
|
||||
render(<PushIngestSettings />);
|
||||
expect(await screen.findByRole("button", { name: /Rotate token/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText("ahp_secret")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PrivacySettings", () => {
|
||||
it("saves the ignored folders one per line", async () => {
|
||||
const user = userEvent.setup();
|
||||
const state = {
|
||||
redact_secrets: false,
|
||||
ignore_cwd: [],
|
||||
env_owned: { redact_secrets: false, ignore_cwd: false },
|
||||
};
|
||||
privacy.get.mockResolvedValue(state);
|
||||
privacy.save.mockImplementation((p) => Promise.resolve({ ok: true, ...state, ...p }));
|
||||
render(<PrivacySettings />);
|
||||
const box = await screen.findByLabelText("Ignored folders");
|
||||
await user.type(box, "/a/b{enter}{enter}/c");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(privacy.save).toHaveBeenCalledWith({ ignore_cwd: ["/a/b", "/c"] }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @file ReportSettings.test.tsx
|
||||
* @description Settings → Reports: a new report is saved with the browser's
|
||||
* timezone offset, Run now runs it, and History lists the runs with download
|
||||
* links built from the report and run ids.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
|
||||
const { reports } = vi.hoisted(() => ({
|
||||
reports: {
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
run: vi.fn(),
|
||||
runs: vi.fn(),
|
||||
fileUrl: (id: number, runId: number, f: string) => `/api/reports/${id}/runs/${runId}/${f}`,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({ api: { reports } }));
|
||||
|
||||
import { ReportSettings } from "../ReportSettings";
|
||||
|
||||
const DEF = {
|
||||
id: 3,
|
||||
name: "Weekly summary",
|
||||
window_days: 7,
|
||||
frequency: "weekly" as const,
|
||||
hour: 8,
|
||||
weekday: 1,
|
||||
tz_offset: 0,
|
||||
formats: ["html" as const, "json" as const],
|
||||
enabled: true,
|
||||
created_at: "2026-10-01T00:00:00Z",
|
||||
last_slot: null,
|
||||
last_run: null,
|
||||
};
|
||||
const RUN = {
|
||||
id: 9,
|
||||
report_id: 3,
|
||||
created_at: "2026-10-05T01:00:00Z",
|
||||
trigger: "manual" as const,
|
||||
status: "ok" as const,
|
||||
window_from: "2026-09-28",
|
||||
window_to: "2026-10-04",
|
||||
files: ["html", "json"],
|
||||
error: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ReportSettings", () => {
|
||||
it("creates a report with the browser's timezone offset", async () => {
|
||||
const user = userEvent.setup();
|
||||
reports.list.mockResolvedValueOnce({ reports: [] }).mockResolvedValue({ reports: [DEF] });
|
||||
reports.create.mockResolvedValue({ report: DEF });
|
||||
render(<ReportSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: /New report/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "JSON" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(reports.create).toHaveBeenCalledWith({
|
||||
name: "Weekly summary",
|
||||
window_days: 7,
|
||||
frequency: "weekly",
|
||||
hour: 8,
|
||||
weekday: 1,
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
formats: ["html", "json"],
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
expect(await screen.findByText(/last 7 days · HTML, JSON/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("runs a report now and lists its runs with download links", async () => {
|
||||
const user = userEvent.setup();
|
||||
reports.list.mockResolvedValue({ reports: [DEF] });
|
||||
reports.run.mockResolvedValue({ run: RUN });
|
||||
reports.runs.mockResolvedValue({ runs: [RUN] });
|
||||
render(<ReportSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Run now" }));
|
||||
expect(reports.run).toHaveBeenCalledWith(3);
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
const link = await screen.findByRole("link", { name: /HTML/ });
|
||||
expect(link).toHaveAttribute("href", "/api/reports/3/runs/9/html");
|
||||
expect(screen.getByText(/2026-09-28 → 2026-10-04/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user