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:
@@ -70,6 +70,8 @@ import {
|
||||
BellOff,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
ListChecks,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -87,9 +89,10 @@ import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { FieldHelp } from "./FieldHelp";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { fmtCost, timeAgo } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -124,6 +127,8 @@ const RULE_TYPES: AlertRuleType[] = [
|
||||
"inactivity",
|
||||
"status_duration",
|
||||
"token_threshold",
|
||||
"cost_threshold",
|
||||
"budget",
|
||||
];
|
||||
|
||||
type TabKey = "rules" | "channels" | "activity";
|
||||
@@ -139,6 +144,11 @@ interface RuleFormState {
|
||||
minutes: string;
|
||||
status: "working" | "waiting";
|
||||
total_tokens: string;
|
||||
usd: string;
|
||||
period: "day" | "week" | "month";
|
||||
limit_usd: string;
|
||||
thresholds: string;
|
||||
budget_tool: string;
|
||||
cooldown_seconds: string;
|
||||
}
|
||||
|
||||
@@ -153,6 +163,11 @@ const EMPTY_FORM: RuleFormState = {
|
||||
minutes: "10",
|
||||
status: "working",
|
||||
total_tokens: "1000000",
|
||||
usd: "5",
|
||||
period: "month",
|
||||
limit_usd: "100",
|
||||
thresholds: "80, 100",
|
||||
budget_tool: "",
|
||||
cooldown_seconds: "300",
|
||||
};
|
||||
|
||||
@@ -171,12 +186,28 @@ function formFromRule(rule: AlertRule): RuleFormState {
|
||||
minutes: str(c.minutes, EMPTY_FORM.minutes),
|
||||
status: c.status === "waiting" ? "waiting" : "working",
|
||||
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
|
||||
usd: str(c.usd, EMPTY_FORM.usd),
|
||||
period: c.period === "day" || c.period === "week" ? c.period : "month",
|
||||
limit_usd: str(c.limit_usd, EMPTY_FORM.limit_usd),
|
||||
thresholds: Array.isArray(c.thresholds) ? c.thresholds.join(", ") : EMPTY_FORM.thresholds,
|
||||
budget_tool: str(c.tool, ""),
|
||||
cooldown_seconds: String(rule.cooldown_seconds),
|
||||
};
|
||||
}
|
||||
|
||||
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** "80, 100" → [80, 100]; null when any part is not a whole percent 1..1000. */
|
||||
function parseThresholds(text: string): number[] | null {
|
||||
const parts = text
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const nums = parts.map((p) => Number(p));
|
||||
if (nums.length === 0 || nums.length > 10) return null;
|
||||
return nums.every((n) => Number.isInteger(n) && n > 0 && n <= 1000) ? nums : null;
|
||||
}
|
||||
|
||||
function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
switch (form.rule_type) {
|
||||
case "event_pattern": {
|
||||
@@ -198,6 +229,18 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
return { status: form.status, minutes: parseFloat(form.minutes) };
|
||||
case "token_threshold":
|
||||
return { total_tokens: parseInt(form.total_tokens, 10) };
|
||||
case "cost_threshold":
|
||||
return { usd: parseFloat(form.usd) };
|
||||
case "budget": {
|
||||
const config: AlertRule["config"] = {
|
||||
period: form.period,
|
||||
limit_usd: parseFloat(form.limit_usd),
|
||||
thresholds: parseThresholds(form.thresholds) ?? [80, 100],
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
};
|
||||
if (form.budget_tool) config.tool = form.budget_tool;
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +268,14 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
|
||||
return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes });
|
||||
case "token_threshold":
|
||||
return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() });
|
||||
case "cost_threshold":
|
||||
return t("ruleDesc.costThreshold", { usd: fmtCost(c.usd ?? 0) });
|
||||
case "budget":
|
||||
return t("ruleDesc.budget", {
|
||||
limit: fmtCost(c.limit_usd ?? 0),
|
||||
period: t(`periods.${c.period ?? "month"}`),
|
||||
thresholds: (c.thresholds ?? []).join("%, ") + "%",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +295,9 @@ export function AlertsNotifications() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
|
||||
const [preview, setPreview] = useState<RulePreview | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const aiTools = useAiTools();
|
||||
const ruleCtx = useContextMenu<AlertRule>();
|
||||
// A rule-menu item: close the menu, then act on the rule it was opened on.
|
||||
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
|
||||
@@ -322,10 +376,35 @@ export function AlertsNotifications() {
|
||||
});
|
||||
}, [loadAlerts]);
|
||||
|
||||
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
|
||||
// Any edit makes a shown preview stale.
|
||||
const set = (patch: Partial<RuleFormState>) => {
|
||||
setPreview(null);
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const onPreview = async () => {
|
||||
if (previewing) return;
|
||||
setPreviewing(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const cooldown = parseInt(form.cooldown_seconds, 10);
|
||||
setPreview(
|
||||
await api.alerts.rules.preview({
|
||||
rule_type: form.rule_type,
|
||||
config: buildConfig(form),
|
||||
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setPreview(null);
|
||||
setEditingId(null);
|
||||
setFormOpen(false);
|
||||
setFormError(null);
|
||||
@@ -333,6 +412,7 @@ export function AlertsNotifications() {
|
||||
|
||||
const openEditRule = (rule: AlertRule) => {
|
||||
setForm(formFromRule(rule));
|
||||
setPreview(null);
|
||||
setEditingId(rule.id);
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
@@ -417,7 +497,10 @@ export function AlertsNotifications() {
|
||||
(countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) &&
|
||||
((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") ||
|
||||
(Number.isFinite(minutesVal) && minutesVal > 0)) &&
|
||||
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0));
|
||||
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0)) &&
|
||||
(form.rule_type !== "cost_threshold" || parseFloat(form.usd) > 0) &&
|
||||
(form.rule_type !== "budget" ||
|
||||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||
{
|
||||
@@ -680,6 +763,100 @@ export function AlertsNotifications() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.rule_type === "cost_threshold" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.usd")}
|
||||
<FieldHelp description={t("rules.help.usd")} />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={form.usd}
|
||||
onChange={(e) => set({ usd: e.target.value })}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.rule_type === "budget" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.period")}
|
||||
<FieldHelp description={t("rules.help.period")} />
|
||||
</span>
|
||||
<div className="relative mt-1">
|
||||
<select
|
||||
value={form.period}
|
||||
onChange={(e) => set({ period: e.target.value as RuleFormState["period"] })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
>
|
||||
{(["day", "week", "month"] as const).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`periods.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.limitUsd")}
|
||||
<FieldHelp description={t("rules.help.limitUsd")} />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={form.limit_usd}
|
||||
onChange={(e) => set({ limit_usd: e.target.value })}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.thresholds")}
|
||||
<FieldHelp description={t("rules.help.thresholds")} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.thresholds}
|
||||
onChange={(e) => set({ thresholds: e.target.value })}
|
||||
placeholder="80, 100"
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.budgetTool")}
|
||||
<FieldHelp description={t("rules.help.budgetTool")} />
|
||||
</span>
|
||||
<div className="relative mt-1">
|
||||
<select
|
||||
value={form.budget_tool}
|
||||
onChange={(e) => set({ budget_tool: e.target.value })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
>
|
||||
<option value="">{t("rules.form.allTools")}</option>
|
||||
{aiTools.tools.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && <RulePreviewResult preview={preview} />}
|
||||
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="mb-1.5 flex items-center gap-1">
|
||||
@@ -701,14 +878,29 @@ export function AlertsNotifications() {
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<button
|
||||
onClick={onSaveRule}
|
||||
disabled={!canSubmit || saving}
|
||||
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPreview}
|
||||
disabled={!canSubmit || previewing}
|
||||
title={t("rules.previewHint")}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
{previewing ? t("rules.previewing") : t("rules.preview")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSaveRule}
|
||||
disabled={!canSubmit || saving}
|
||||
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{editingId ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{formError && <p className="text-xs text-status-danger">{formError}</p>}
|
||||
</div>
|
||||
@@ -927,3 +1119,51 @@ export function AlertsNotifications() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a rule would have fired, from POST /api/alerts/rules/preview. */
|
||||
function RulePreviewResult({ preview }: { preview: RulePreview }) {
|
||||
const { t } = useTranslation("alerts");
|
||||
if (!preview.supported) {
|
||||
return <p className="text-xs text-fg-muted">{t("rules.previewUnsupported")}</p>;
|
||||
}
|
||||
if (preview.periods) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
|
||||
<p className="text-fg-secondary">{t("rules.previewBudget")}</p>
|
||||
{preview.periods.map((p) => (
|
||||
<div key={p.period_start} className="flex justify-between gap-3 font-mono">
|
||||
<span className="text-fg-muted">{p.period_start}</span>
|
||||
<span className="text-fg-secondary">
|
||||
{fmtCost(p.spend)} · {Math.round(p.pct)}%
|
||||
</span>
|
||||
<span className={p.crossed.length ? "text-status-warning" : "text-fg-muted"}>
|
||||
{p.crossed.length ? p.crossed.map((c) => `${c}%`).join(" ") : "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
|
||||
<p className="text-fg-secondary">
|
||||
{t("rules.previewResult", {
|
||||
fires: preview.fires,
|
||||
sessions: preview.sessions,
|
||||
days: preview.window_days,
|
||||
})}
|
||||
</p>
|
||||
{preview.samples.map((s, i) => (
|
||||
<div key={i} className="flex justify-between gap-3 font-mono text-fg-muted">
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(s.session_id)}`}
|
||||
className="truncate hover:text-accent"
|
||||
>
|
||||
{s.session_id.slice(0, 8)}
|
||||
</Link>
|
||||
<span>{timeAgo(s.at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export function DocumentTitle() {
|
||||
title = t("workspace");
|
||||
} else if (path.startsWith("/settings")) {
|
||||
title = t("settings");
|
||||
} else if (path.startsWith("/query")) {
|
||||
title = t("query");
|
||||
} else if (path !== "/") {
|
||||
title = t("notFound");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* @file EventReplay.tsx
|
||||
* @description The session Replay tab: a scrubber over a session's recorded
|
||||
* events (play/pause, 0.5×–5×, step, jump, arrow keys), a lifecycle mini-map
|
||||
* whose markers jump the cursor, the state derived up to the cursor
|
||||
* (`lib/replay.ts`) and the selected event through EventDetail. Fed a list of
|
||||
* events, so the live analysis page and a frozen snapshot share it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import {
|
||||
chronological,
|
||||
lifecycleMarkers,
|
||||
replayState,
|
||||
LIFECYCLE_TYPES,
|
||||
REPLAY_SPEEDS,
|
||||
REPLAY_STEP_MS,
|
||||
} from "../lib/replay";
|
||||
import { buildEventTitle } from "../lib/event-grouping";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import { formatDateTimeFull, formatMs } from "../lib/format";
|
||||
import type { DashboardEvent } from "../lib/types";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { EventDetail } from "./EventDetail";
|
||||
|
||||
/** The newest this many events are replayed; older ones are left out. */
|
||||
export const REPLAY_MAX_EVENTS = 5000;
|
||||
const PAGE = 500;
|
||||
|
||||
/** A session's events in playback order (the newest REPLAY_MAX_EVENTS). */
|
||||
export async function loadReplayEvents(
|
||||
sessionId: string
|
||||
): Promise<{ events: DashboardEvent[]; total: number }> {
|
||||
const all: DashboardEvent[] = [];
|
||||
let total = 0;
|
||||
do {
|
||||
const r = await api.events.list({ session_id: sessionId, limit: PAGE, offset: all.length });
|
||||
total = r.total;
|
||||
if (r.events.length === 0) break;
|
||||
all.push(...r.events);
|
||||
} while (all.length < Math.min(total, REPLAY_MAX_EVENTS));
|
||||
return { events: chronological(all.slice(0, REPLAY_MAX_EVENTS)), total };
|
||||
}
|
||||
|
||||
const MARKER_COLOR: Record<string, string> = {
|
||||
SessionStart: "bg-status-success",
|
||||
UserPromptSubmit: "bg-accent",
|
||||
Compaction: "bg-status-warning",
|
||||
SubagentStop: "bg-violet-500",
|
||||
APIError: "bg-status-danger",
|
||||
Stop: "bg-fg-muted",
|
||||
};
|
||||
|
||||
const btn =
|
||||
"inline-flex h-8 w-8 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed";
|
||||
|
||||
export function EventReplay({
|
||||
events: raw,
|
||||
total,
|
||||
agentInfoById,
|
||||
sessionNameById,
|
||||
}: {
|
||||
events: DashboardEvent[];
|
||||
/** The session's full event count, when more than `events` were recorded. */
|
||||
total?: number;
|
||||
agentInfoById: Map<string, AgentInfo>;
|
||||
sessionNameById: Map<string, string>;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const events = useMemo(() => chronological(raw), [raw]);
|
||||
const markers = useMemo(() => lifecycleMarkers(events), [events]);
|
||||
const last = events.length - 1;
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState<number>(1);
|
||||
const at = Math.min(cursor, Math.max(0, last));
|
||||
const state = useMemo(() => replayState(events, at), [events, at]);
|
||||
const event = events[at];
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
if (at >= last) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setCursor((c) => Math.min(c + 1, last)), REPLAY_STEP_MS / speed);
|
||||
return () => clearTimeout(timer);
|
||||
}, [playing, at, last, speed]);
|
||||
|
||||
if (!event) return <p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>;
|
||||
|
||||
const go = (i: number) => setCursor(Math.max(0, Math.min(last, i)));
|
||||
const toggle = () => {
|
||||
if (!playing && at >= last) setCursor(0);
|
||||
setPlaying((p) => !p);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// The slider steps itself on arrow keys; only the rest is ours there.
|
||||
const onSlider = e.target instanceof HTMLInputElement;
|
||||
if (e.key === " " && !(e.target instanceof HTMLButtonElement)) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
} else if (onSlider) return;
|
||||
else if (e.key === "ArrowLeft") go(at - 1);
|
||||
else if (e.key === "ArrowRight") go(at + 1);
|
||||
else if (e.key === "Home") go(0);
|
||||
else if (e.key === "End") go(last);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
const markerLabel = (type: string) => t(`replay.marker.${type}`);
|
||||
const present = LIFECYCLE_TYPES.filter((k) => markers.some((m) => m.type === k));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-4 outline-none"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKey}
|
||||
role="group"
|
||||
aria-label={t("replay.label")}
|
||||
data-testid="event-replay"
|
||||
>
|
||||
{total != null && total > events.length && (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("replay.truncated", { shown: events.length, total })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="card space-y-3 p-4">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button type="button" className={btn} onClick={() => go(0)} title={t("replay.start")}>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
onClick={() => go(at - 1)}
|
||||
disabled={at <= 0}
|
||||
title={t("replay.back")}
|
||||
>
|
||||
<StepBack className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${btn} bg-accent/15 text-accent`}
|
||||
onClick={toggle}
|
||||
title={playing ? t("replay.pause") : t("replay.play")}
|
||||
aria-pressed={playing}
|
||||
>
|
||||
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
onClick={() => go(at + 1)}
|
||||
disabled={at >= last}
|
||||
title={t("replay.forward")}
|
||||
>
|
||||
<StepForward className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" className={btn} onClick={() => go(last)} title={t("replay.end")}>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
className="ml-2 flex items-center gap-0.5"
|
||||
role="group"
|
||||
aria-label={t("replay.speed")}
|
||||
>
|
||||
{REPLAY_SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSpeed(s)}
|
||||
aria-pressed={speed === s}
|
||||
className={`rounded px-2 py-1 text-xs font-mono ${
|
||||
speed === s
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="ml-auto font-mono text-xs text-fg-muted">
|
||||
{t("replay.position", { at: at + 1, n: events.length })} ·{" "}
|
||||
{formatDateTimeFull(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={last}
|
||||
value={at}
|
||||
onChange={(e) => go(Number(e.target.value))}
|
||||
aria-label={t("replay.scrubber")}
|
||||
className="w-full accent-[rgb(var(--accent))]"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="relative h-4 rounded bg-surface-2"
|
||||
role="group"
|
||||
aria-label={t("replay.minimap")}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-fg-primary"
|
||||
style={{ left: `${last > 0 ? (at / last) * 100 : 0}%` }}
|
||||
/>
|
||||
{markers.map((m) => (
|
||||
<button
|
||||
key={m.index}
|
||||
type="button"
|
||||
onClick={() => go(m.index)}
|
||||
title={`${markerLabel(m.type)} · #${m.index + 1}`}
|
||||
aria-label={`${markerLabel(m.type)} · #${m.index + 1}`}
|
||||
className={`absolute inset-y-0 w-1.5 -translate-x-1/2 rounded-sm opacity-80 hover:opacity-100 ${MARKER_COLOR[m.type]}`}
|
||||
style={{ left: `${m.pct}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{present.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-fg-muted">
|
||||
{present.map((k) => (
|
||||
<span key={k} className="inline-flex items-center gap-1">
|
||||
<span className={`h-2 w-2 rounded-sm ${MARKER_COLOR[k]}`} />
|
||||
{markerLabel(k)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-6">
|
||||
<Tile label={t("replay.state.status")}>
|
||||
{state.status ? <AgentStatusBadge status={state.status} /> : "-"}
|
||||
</Tile>
|
||||
<Tile label={t("replay.state.tool")}>
|
||||
<span className="font-mono">{state.currentTool ?? "-"}</span>
|
||||
</Tile>
|
||||
<Tile label={t("replay.state.prompts")}>{state.prompts}</Tile>
|
||||
<Tile label={t("replay.state.errors")}>{state.errors}</Tile>
|
||||
<Tile label={t("replay.state.compactions")}>{state.compactions}</Tile>
|
||||
<Tile label={t("replay.state.elapsed")}>{formatMs(state.elapsedMs)}</Tile>
|
||||
</div>
|
||||
{state.toolCounts.length > 0 && (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={t("replay.state.toolCounts")}
|
||||
>
|
||||
{state.toolCounts.map((x) => (
|
||||
<span
|
||||
key={x.tool}
|
||||
className="rounded bg-surface-2 px-2 py-0.5 font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{x.tool} ×{x.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="border-b border-border px-5 py-3 text-sm text-fg-secondary">
|
||||
<span className="mr-2 font-mono text-xs text-fg-muted">#{at + 1}</span>
|
||||
{buildEventTitle(event)}
|
||||
</div>
|
||||
<EventDetail
|
||||
event={event}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted">{label}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-fg-primary">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file EventTimeline.tsx
|
||||
* @description A session's events as a flat, expandable list — one row per
|
||||
* event (age, status badge, origin · title, tool), opening into EventDetail.
|
||||
* Used by the session analysis page's Timeline tab and the read-only snapshot
|
||||
* page.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { EventDetail } from "./EventDetail";
|
||||
import {
|
||||
agentOriginLabel,
|
||||
buildEventTitle,
|
||||
buildOriginLabel,
|
||||
projectFromCwd,
|
||||
projectFromEvent,
|
||||
statusFromEventType,
|
||||
} from "../lib/event-grouping";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import type { DashboardEvent } from "../lib/types";
|
||||
|
||||
export function EventTimeline({
|
||||
events,
|
||||
agentInfoById,
|
||||
sessionNameById,
|
||||
fallbackCwd,
|
||||
}: {
|
||||
events: DashboardEvent[];
|
||||
agentInfoById: Map<string, AgentInfo>;
|
||||
sessionNameById: Map<string, string>;
|
||||
/** The session's cwd, for events that carry none of their own. */
|
||||
fallbackCwd?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
|
||||
|
||||
function toggleEvent(id: number) {
|
||||
setExpandedEvents((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Precompute project per event so flat-row rendering doesn't re-parse JSON.
|
||||
const projectByEventId = useMemo(() => {
|
||||
const map = new Map<number, string | null>();
|
||||
for (const e of events) map.set(e.id, projectFromEvent(e));
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
|
||||
{events.map((event, i) => {
|
||||
const key = event.id ?? i;
|
||||
const isOpen = event.id != null && expandedEvents.has(event.id);
|
||||
return (
|
||||
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
|
||||
<div
|
||||
key={key}
|
||||
style={
|
||||
isOpen
|
||||
? undefined
|
||||
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => event.id != null && toggleEvent(event.id)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={
|
||||
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
|
||||
}
|
||||
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
|
||||
>
|
||||
<span
|
||||
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</div>
|
||||
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
|
||||
{(() => {
|
||||
// Session is implicit on this page - project is
|
||||
// still shown so the row identifies the working
|
||||
// directory when you share / search. Events without
|
||||
// their own cwd (e.g. TurnDuration) fall back to the
|
||||
// session's cwd so every row carries the dir prefix.
|
||||
const project =
|
||||
projectByEventId.get(event.id) ?? projectFromCwd(fallbackCwd) ?? null;
|
||||
const origin = buildOriginLabel(
|
||||
project,
|
||||
null,
|
||||
agentOriginLabel(event.agent_id, agentInfoById)
|
||||
);
|
||||
return (
|
||||
<span className="text-sm text-fg-secondary flex-1 truncate">
|
||||
{origin && (
|
||||
<span className="text-fg-muted mr-1" title={event.agent_id ?? undefined}>
|
||||
{origin} ·
|
||||
</span>
|
||||
)}
|
||||
{buildEventTitle(event)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{event.tool_name && (
|
||||
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
|
||||
{event.tool_name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<EventDetail
|
||||
event={event}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,11 @@
|
||||
* written back on every toggle. Failures to access storage are swallowed so a
|
||||
* private-browsing quota error never breaks the UI.
|
||||
*
|
||||
* ## Sidebar width
|
||||
* The expanded sidebar's right edge drags (or takes ←/→) between
|
||||
* {@link SIDEBAR_MIN} and {@link SIDEBAR_MAX} px — never past 40% of the
|
||||
* window — kept in `agenthub.sidebar.width`; a double-click resets it.
|
||||
*
|
||||
* ## Narrow viewports
|
||||
* With no stored preference the sidebar starts collapsed below `lg` (see
|
||||
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
|
||||
@@ -79,6 +84,26 @@ import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { Sagi } from "./Sagi/Sagi";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
|
||||
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||
/** The default width, and the narrowest the edge drags to. */
|
||||
export const SIDEBAR_MIN = 240;
|
||||
/** Wide enough for long workspace names on a large screen, no wider. */
|
||||
export const SIDEBAR_MAX = 480;
|
||||
|
||||
/** A width inside the bounds, and at most 40% of the window. */
|
||||
export function clampSidebar(px: number): number {
|
||||
const max = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth * 0.4));
|
||||
return Math.round(Math.min(max, Math.max(SIDEBAR_MIN, px)));
|
||||
}
|
||||
|
||||
function loadWidth(): number {
|
||||
try {
|
||||
const n = Number(localStorage.getItem(WIDTH_KEY));
|
||||
if (n) return clampSidebar(n);
|
||||
} catch {}
|
||||
return SIDEBAR_MIN;
|
||||
}
|
||||
|
||||
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
|
||||
export const DRAWER_QUERY = "(max-width: 767.98px)";
|
||||
|
||||
@@ -112,7 +137,17 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||
const { pathname, search } = useLocation();
|
||||
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem";
|
||||
const [width, setWidth] = useState(loadWidth);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : `${width}px`;
|
||||
|
||||
const saveWidth = useCallback((px: number) => {
|
||||
const next = clampSidebar(px);
|
||||
setWidth(next);
|
||||
try {
|
||||
localStorage.setItem(WIDTH_KEY, String(next));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
// A pick in the drawer navigates; the drawer has done its job.
|
||||
useEffect(closeDrawer, [pathname, search, closeDrawer]);
|
||||
@@ -153,11 +188,52 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
connectionOpen={connectionOpen}
|
||||
onConnectionOpenChange={setConnectionOpen}
|
||||
onUpdateAvailableChange={setUpdateAvailable}
|
||||
width={width}
|
||||
resizing={resizing}
|
||||
/>
|
||||
{!drawer && !collapsed && (
|
||||
<div
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={SIDEBAR_MIN}
|
||||
aria-valuemax={SIDEBAR_MAX}
|
||||
aria-label={t("resizeSidebar")}
|
||||
title={t("resizeSidebar")}
|
||||
data-testid="sidebar-resize"
|
||||
className={`fixed top-0 bottom-0 z-30 w-1.5 -translate-x-1/2 cursor-col-resize outline-none transition-colors hover:bg-accent/40 focus-visible:bg-accent/60 ${
|
||||
resizing ? "bg-accent/60" : ""
|
||||
}`}
|
||||
style={{ left: width }}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
setResizing(true);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (resizing) setWidth(clampSidebar(e.clientX));
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!resizing) return;
|
||||
setResizing(false);
|
||||
saveWidth(e.clientX);
|
||||
}}
|
||||
onPointerCancel={() => setResizing(false)}
|
||||
onDoubleClick={() => saveWidth(SIDEBAR_MIN)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
saveWidth(width + (e.key === "ArrowRight" ? 16 : -16));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="min-h-screen min-w-0 pb-6 transition-[margin-left,width] duration-200 outline-none"
|
||||
className={`min-h-screen min-w-0 pb-6 duration-200 outline-none ${
|
||||
resizing ? "" : "transition-[margin-left,width]"
|
||||
}`}
|
||||
style={{
|
||||
marginLeft: sidebarWidth,
|
||||
width: `calc(100% - ${sidebarWidth})`,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file RemoteSources.tsx
|
||||
* @description Settings UI for the Remote Data Sources feature: manage the SSH
|
||||
* machines this dashboard pulls Claude Code history from, and choose the global
|
||||
* machines this dashboard pulls Claude Code (and, opted in per source, Codex)
|
||||
* history from, and choose the global
|
||||
* "data scope" (which machines' data the whole app shows).
|
||||
*
|
||||
* Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the
|
||||
@@ -97,6 +98,8 @@ const EMPTY_FORM: RemoteSourceInput = {
|
||||
ssh_port: null,
|
||||
identity_file: "",
|
||||
remote_home: "",
|
||||
sync_codex: false,
|
||||
remote_codex_home: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
@@ -219,6 +222,8 @@ export function RemoteSources() {
|
||||
ssh_port: s.ssh_port,
|
||||
identity_file: s.identity_file || "",
|
||||
remote_home: s.remote_home || "",
|
||||
sync_codex: !!s.sync_codex,
|
||||
remote_codex_home: s.remote_codex_home || "",
|
||||
enabled: s.enabled,
|
||||
});
|
||||
setEditingId(s.id);
|
||||
@@ -242,6 +247,8 @@ export function RemoteSources() {
|
||||
ssh_port: form.ssh_port ? Number(form.ssh_port) : null,
|
||||
identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null,
|
||||
remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null,
|
||||
sync_codex: !!form.sync_codex,
|
||||
remote_codex_home: form.remote_codex_home?.trim() ? form.remote_codex_home.trim() : null,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
try {
|
||||
@@ -591,6 +598,31 @@ export function RemoteSources() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={!!form.sync_codex}
|
||||
onChange={(v) => setForm((f) => ({ ...f, sync_codex: v }))}
|
||||
label={t("remoteSources.fieldSyncCodex", "Also pull Codex sessions")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
{form.sync_codex && (
|
||||
<div>
|
||||
<label className="block text-xs text-fg-muted mb-1">
|
||||
{t("remoteSources.fieldRemoteCodexHome", "Remote Codex home (optional)")}
|
||||
</label>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
placeholder="~/.codex"
|
||||
value={form.remote_codex_home ?? ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remote_codex_home: e.target.value }))}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-fg-muted leading-snug">
|
||||
{t(
|
||||
"remoteSources.fieldRemoteCodexHomeHint",
|
||||
"Default ~/.codex (wsl:~/.codex when the Claude home is in WSL). Its sessions/ folder is copied, and the sessions show up as Codex sessions of this source, with their tokens and cost."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Checkbox
|
||||
checked={!!form.enabled}
|
||||
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
||||
@@ -674,6 +706,9 @@ export function RemoteSources() {
|
||||
{s.host}
|
||||
{s.ssh_port ? `:${s.ssh_port}` : ""}
|
||||
{s.remote_home ? ` · ${s.remote_home}` : ""}
|
||||
{s.sync_codex
|
||||
? ` · ${t("remoteSources.codexOn", "+ Codex")}${s.remote_codex_home ? ` (${s.remote_codex_home})` : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted mt-1">
|
||||
{s.last_sync_at
|
||||
@@ -689,9 +724,19 @@ export function RemoteSources() {
|
||||
` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", {
|
||||
n: s.last_sync_counts.sessions_tagged,
|
||||
})}`}
|
||||
{s.last_sync_counts?.codex_sessions != null &&
|
||||
` · ${t("remoteSources.syncCodex", "{{n}} Codex", {
|
||||
n: s.last_sync_counts.codex_sessions,
|
||||
})}`}
|
||||
</div>
|
||||
{s.status === "error" && s.last_error && (
|
||||
<div className="text-[11px] text-status-danger mt-1 break-words">
|
||||
{/* A sync that pulled one CLI's history but not the other's is
|
||||
`ok` with the other's failure in last_error. */}
|
||||
{s.last_error && (s.status === "error" || s.status === "ok") && (
|
||||
<div
|
||||
className={`text-[11px] mt-1 break-words ${
|
||||
s.status === "error" ? "text-status-danger" : "text-status-warning"
|
||||
}`}
|
||||
>
|
||||
{s.last_error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
|
||||
* or waving row over the mood's, like Codex's own pet. Each row steps at its
|
||||
* own pace and holds its last frame longer, so a loop breathes instead of
|
||||
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||
* back to Sagi instead of an empty box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -30,11 +33,28 @@ const ROW: Record<Mood, number> = {
|
||||
worried: 5, // failed
|
||||
disconnected: 5,
|
||||
};
|
||||
/** What the user is doing to the pet, which outranks its mood. */
|
||||
export type PetAction = "run-left" | "run-right" | "wave";
|
||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
|
||||
const PACE: [number, number][] = [
|
||||
[220, 1400], // idle: a breath, then a pause
|
||||
[120, 120], // run right — keeps up with the drag
|
||||
[120, 120], // run left
|
||||
[180, 400], // waving
|
||||
[160, 700], // jumping
|
||||
[200, 1200], // failed
|
||||
[220, 900], // waiting
|
||||
[170, 400], // running (working)
|
||||
[220, 900], // review
|
||||
];
|
||||
const SLEEP_PACE: [number, number] = [450, 2000];
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
action,
|
||||
size,
|
||||
reducedMotion,
|
||||
onBroken,
|
||||
@@ -42,23 +62,36 @@ export function PetSprite({
|
||||
id: string;
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
action?: PetAction | null;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
onBroken: () => void;
|
||||
}) {
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = ROW[mood] ?? 0;
|
||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && mood === "sleeping";
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(0);
|
||||
if (kind !== "codex" || reducedMotion) return;
|
||||
const timer = window.setInterval(
|
||||
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
|
||||
mood === "sleeping" ? 400 : 125
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [kind, row, mood, reducedMotion]);
|
||||
const count = FRAMES[row] ?? 1;
|
||||
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
|
||||
let f = 0;
|
||||
let timer = 0;
|
||||
const tick = () => {
|
||||
timer = window.setTimeout(
|
||||
() => {
|
||||
f = (f + 1) % count;
|
||||
setFrame(f);
|
||||
tick();
|
||||
},
|
||||
f === count - 1 ? hold : step
|
||||
);
|
||||
};
|
||||
tick();
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [kind, row, sleeping, reducedMotion]);
|
||||
|
||||
// A background image reports no error of its own: probe it once.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -203,6 +203,7 @@ export function Sagi() {
|
||||
const [open, setOpen] = useState(false);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const navigate = useNavigate();
|
||||
const brain = useSagiBrain();
|
||||
@@ -331,6 +332,8 @@ export function Sagi() {
|
||||
className="sagi-avatar-btn"
|
||||
data-dragging={place.dragging ? "1" : "0"}
|
||||
style={{ left: place.left, top: place.top, width: size, height: size }}
|
||||
onPointerEnter={() => setHover(true)}
|
||||
onPointerLeave={() => setHover(false)}
|
||||
onPointerDown={place.onPointerDown}
|
||||
onPointerMove={place.onPointerMove}
|
||||
onPointerUp={place.onPointerUp}
|
||||
@@ -358,6 +361,8 @@ export function Sagi() {
|
||||
id={pet.id}
|
||||
kind={pet.kind}
|
||||
mood={brain.mood}
|
||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
// Deleted elsewhere, or unreadable: back to Sagi.
|
||||
|
||||
@@ -94,6 +94,8 @@ describe("PetSprite", () => {
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
||||
act(() => vi.advanceTimersByTime(130));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
|
||||
act(() => vi.advanceTimersByTime(50));
|
||||
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
||||
rerender(
|
||||
<PetSprite
|
||||
@@ -108,6 +110,45 @@ describe("PetSprite", () => {
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
|
||||
});
|
||||
|
||||
it("runs the way it is dragged and waves on hover, over its mood", () => {
|
||||
const props = {
|
||||
id: "cp-goku",
|
||||
kind: "codex" as const,
|
||||
mood: "thinking" as const,
|
||||
size: 208,
|
||||
reducedMotion: false,
|
||||
onBroken: () => {},
|
||||
};
|
||||
const { rerender } = render(<PetSprite {...props} action="run-left" />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("2");
|
||||
rerender(<PetSprite {...props} action="run-right" />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("1");
|
||||
rerender(<PetSprite {...props} action="wave" />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("3");
|
||||
rerender(<PetSprite {...props} action={null} />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
|
||||
});
|
||||
|
||||
it("holds a loop's last frame before starting over", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="idle"
|
||||
size={208}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("holds the first frame under reduced motion", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
fireEvent,
|
||||
act,
|
||||
cleanup,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { useSagiPosition } from "../useSagiPosition";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sagi } from "../Sagi";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
@@ -192,3 +201,22 @@ describe("Sagi widget", () => {
|
||||
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSagiPosition moving", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("names the drag's direction and settles once the pointer rests", () => {
|
||||
const { result } = renderHook(() => useSagiPosition(68));
|
||||
const target = { setPointerCapture() {}, releasePointerCapture() {} };
|
||||
const ev = (x: number) =>
|
||||
({ button: 0, pointerId: 1, clientX: x, clientY: 300, currentTarget: target }) as never;
|
||||
act(() => result.current.onPointerDown(ev(500)));
|
||||
act(() => result.current.onPointerMove(ev(480)));
|
||||
expect(result.current.moving).toBe("left");
|
||||
act(() => result.current.onPointerMove(ev(520)));
|
||||
expect(result.current.moving).toBe("right");
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(result.current.moving).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* viewport fraction so it survives resizes). A small movement threshold tells
|
||||
* a drag apart from a tap so dragging never opens the panel. The arrow keys
|
||||
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
|
||||
* `moving` names the way it is heading — while dragged (cleared once the
|
||||
* pointer rests) and during the glide to its edge — so a pet can run that way.
|
||||
* It never rests over the fixed bottom status bar; by default it docks
|
||||
* bottom-right just above it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -83,6 +85,8 @@ const DRAG_THRESHOLD = 5;
|
||||
const STATUS_BAR = 24;
|
||||
// One arrow-key step, in px.
|
||||
const KEY_STEP = 40;
|
||||
// A held but unmoving drag stops running after this; also the snap glide's length.
|
||||
const MOVE_SETTLE_MS = 300;
|
||||
|
||||
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
||||
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
||||
@@ -109,6 +113,8 @@ export interface SagiPlacement {
|
||||
/** True when the avatar sits in the lower half - flyouts open upward. */
|
||||
openUp: boolean;
|
||||
dragging: boolean;
|
||||
/** The way the avatar is moving horizontally right now, if it is. */
|
||||
moving: "left" | "right" | null;
|
||||
onPointerDown: (e: ReactPointerEvent) => void;
|
||||
onPointerMove: (e: ReactPointerEvent) => void;
|
||||
onPointerUp: (e: ReactPointerEvent) => void;
|
||||
@@ -123,6 +129,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
|
||||
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
||||
const [, force] = useState(0); // re-derive resting coords on resize
|
||||
const [moving, setMoving] = useState<"left" | "right" | null>(null);
|
||||
const moveTimer = useRef(0);
|
||||
const lastX = useRef(0);
|
||||
const heading = useCallback((dir: "left" | "right" | null) => {
|
||||
window.clearTimeout(moveTimer.current);
|
||||
setMoving(dir);
|
||||
if (dir) moveTimer.current = window.setTimeout(() => setMoving(null), MOVE_SETTLE_MS);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(moveTimer.current), []);
|
||||
|
||||
const draggedRef = useRef(false);
|
||||
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
|
||||
@@ -152,6 +167,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
/* capture unsupported - window-free fallback still works via props */
|
||||
}
|
||||
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
|
||||
lastX.current = e.clientX;
|
||||
movedRef.current = false;
|
||||
},
|
||||
[screen.left, screen.top]
|
||||
@@ -172,8 +188,14 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
);
|
||||
liveRef.current = { left, top };
|
||||
setDrag({ left, top });
|
||||
// Ignore a pixel of jitter; a steady push keeps re-arming the settle timer.
|
||||
const stepX = e.clientX - lastX.current;
|
||||
if (Math.abs(stepX) >= 2) {
|
||||
lastX.current = e.clientX;
|
||||
heading(stepX > 0 ? "right" : "left");
|
||||
}
|
||||
},
|
||||
[size]
|
||||
[size, heading]
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
@@ -193,12 +215,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||
// Run along the glide to the edge, when there is a real distance to cover.
|
||||
const glide = restingScreen(next, size).left - live.left;
|
||||
heading(Math.abs(glide) > size / 2 ? (glide > 0 ? "right" : "left") : null);
|
||||
}
|
||||
liveRef.current = null;
|
||||
startRef.current = null;
|
||||
movedRef.current = false;
|
||||
},
|
||||
[size]
|
||||
[size, heading]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
@@ -214,10 +239,11 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
}
|
||||
if (!next) return;
|
||||
e.preventDefault();
|
||||
if (next.side !== pos.side) heading(next.side);
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
},
|
||||
[pos, size]
|
||||
[pos, size, heading]
|
||||
);
|
||||
|
||||
const consumeDrag = useCallback(() => {
|
||||
@@ -233,6 +259,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
side: pos.side,
|
||||
openUp: screen.top + size / 2 > vh() / 2,
|
||||
dragging: drag !== null,
|
||||
moving,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
|
||||
@@ -76,6 +76,8 @@ import type { Agent, Session, SessionStats } from "../lib/types";
|
||||
interface SessionOverviewProps {
|
||||
session: Session;
|
||||
agents: Agent[];
|
||||
/** Frozen stats (a session snapshot): shown as given, never fetched or refreshed. */
|
||||
stats?: SessionStats;
|
||||
}
|
||||
|
||||
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
|
||||
@@ -160,18 +162,19 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
export function SessionOverview({ session, agents, stats: frozen }: SessionOverviewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [stats, setStats] = useState<SessionStats | null>(null);
|
||||
const [fetched, setStats] = useState<SessionStats | null>(null);
|
||||
const stats = frozen ?? fetched;
|
||||
const fetchingRef = useRef(false);
|
||||
|
||||
// Tick clock every 30s so a still-active session's "duration" tile stays current.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (session.status !== "active") return;
|
||||
if (frozen || session.status !== "active") return;
|
||||
const id = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [session.status]);
|
||||
}, [frozen, session.status]);
|
||||
|
||||
// A refresh asked for while one is in flight is not dropped: it runs once
|
||||
// when the current one settles (that is the throttle's trailing call).
|
||||
@@ -198,6 +201,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
|
||||
// Initial load + reload when the session id changes.
|
||||
useEffect(() => {
|
||||
if (frozen) return;
|
||||
fetchStats();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id]);
|
||||
@@ -206,6 +210,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
// stream still refreshes) and again after a reconnect.
|
||||
useLiveRefresh(() => void fetchStats(), {
|
||||
throttleMs: REFRESH_THROTTLE_MS,
|
||||
enabled: !frozen,
|
||||
match: (msg) => {
|
||||
if (isRemoteDataRefreshMessage(msg)) return true;
|
||||
if (
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
TableProperties,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -83,6 +84,7 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
const NAV_KEYS = [
|
||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||
{ to: "/query", icon: TableProperties, key: "nav:query" },
|
||||
] as const;
|
||||
|
||||
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
||||
@@ -170,6 +172,10 @@ interface SidebarProps {
|
||||
onUpdateAvailableChange?: (available: boolean) => void;
|
||||
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
|
||||
drawer?: { open: boolean; onClose: () => void };
|
||||
/** Expanded width in px, chosen by dragging its edge (see Layout). */
|
||||
width?: number;
|
||||
/** True while that edge is dragged: no width transition lagging the pointer. */
|
||||
resizing?: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -180,6 +186,8 @@ export function Sidebar({
|
||||
onConnectionOpenChange,
|
||||
onUpdateAvailableChange,
|
||||
drawer,
|
||||
width = 240,
|
||||
resizing = false,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
@@ -423,8 +431,9 @@ export function Sidebar({
|
||||
? "visible translate-x-0 shadow-2xl transition-transform"
|
||||
: "invisible -translate-x-full transition-[transform,visibility]"
|
||||
}`
|
||||
: `z-30 transition-[width] duration-200 ${collapsed ? "w-[4.25rem]" : "w-60"}`
|
||||
: `z-30 duration-200 ${resizing ? "" : "transition-[width]"} ${collapsed ? "w-[4.25rem]" : ""}`
|
||||
}`}
|
||||
style={drawer || collapsed ? undefined : { width }}
|
||||
>
|
||||
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file SnapshotButton.tsx
|
||||
* @description The session analysis page's **Snapshot** action: confirms with a
|
||||
* "redact file paths" choice, freezes the session through `POST
|
||||
* /api/snapshots`, copies the `/snapshot/<token>` link and offers it inline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Camera } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { CopyButton } from "./event-views/primitives";
|
||||
|
||||
export function snapshotUrl(token: string): string {
|
||||
return `${window.location.origin}/snapshot/${token}`;
|
||||
}
|
||||
|
||||
export function SnapshotButton({ sessionId }: { sessionId: string }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [redact, setRedact] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
|
||||
setToken(snapshot.token);
|
||||
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{token && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-fg-secondary">
|
||||
<Link to={`/snapshot/${token}`} className="text-accent hover:underline">
|
||||
{t("snapshot.open")}
|
||||
</Link>
|
||||
<CopyButton text={snapshotUrl(token)} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t("snapshot.hint")}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
{t("snapshot.create")}
|
||||
</button>
|
||||
{error && (
|
||||
<p role="alert" className="basis-full text-right text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t("snapshot.title")}
|
||||
message={t("snapshot.body")}
|
||||
confirmLabel={t("snapshot.confirm")}
|
||||
cancelLabel={t("snapshot.cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
onConfirm={() => void create()}
|
||||
onCancel={() => setOpen(false)}
|
||||
>
|
||||
<Checkbox checked={redact} onChange={setRedact} label={t("snapshot.redactPaths")} />
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,10 +23,10 @@
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import type { CostResult, ToolQuota } from "../lib/types";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
@@ -342,6 +342,8 @@ export function StatusBar({
|
||||
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
|
||||
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
// The budget furthest past one of its thresholds, if any.
|
||||
const [budget, setBudget] = useState<BudgetStatus | null>(null);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
const lane = shown.lane;
|
||||
@@ -381,6 +383,13 @@ export function StatusBar({
|
||||
.totalCost({ date: localDate() })
|
||||
.then((r) => live && setToday(r))
|
||||
.catch(() => undefined);
|
||||
api.budgets
|
||||
.status()
|
||||
.then(({ budgets }) => {
|
||||
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
|
||||
if (live) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
@@ -470,6 +479,20 @@ export function StatusBar({
|
||||
r={sessionCost}
|
||||
/>
|
||||
)}
|
||||
{budget && (
|
||||
<Link
|
||||
to="/?tab=analytics&sub=cost"
|
||||
data-testid="status-bar-budget"
|
||||
title={t("statusBar.budgetTitle", {
|
||||
name: budget.name,
|
||||
spend: usd(budget.spend),
|
||||
limit: usd(budget.config.limit_usd ?? 0),
|
||||
})}
|
||||
className={`tabular-nums hover:underline ${budget.pct >= 100 ? "text-status-danger" : "text-status-warning"}`}
|
||||
>
|
||||
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
||||
</Link>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file Tests for the alert rule form's spend rules: a budget rule is built
|
||||
* from period / limit / thresholds / AI tool (plus the browser's timezone
|
||||
* offset), Preview shows what it would have fired without saving it, and
|
||||
* Create saves the same config.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
alerts: {
|
||||
list: vi.fn().mockResolvedValue({ alerts: [], total: 0, unacked: 0, limit: 50, offset: 0 }),
|
||||
rules: {
|
||||
list: vi.fn().mockResolvedValue({ rules: [] }),
|
||||
create: vi.fn().mockResolvedValue({ rule: {} }),
|
||||
preview: vi.fn().mockResolvedValue({
|
||||
supported: true,
|
||||
window_days: 7,
|
||||
fires: 1,
|
||||
sessions: 0,
|
||||
samples: [],
|
||||
periods: [
|
||||
{
|
||||
period_start: "2026-09-01",
|
||||
period_end: "2026-09-30",
|
||||
spend: 120,
|
||||
pct: 120,
|
||||
crossed: [80, 100],
|
||||
},
|
||||
{
|
||||
period_start: "2026-10-01",
|
||||
period_end: "2026-10-31",
|
||||
spend: 10,
|
||||
pct: 10,
|
||||
crossed: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
tools: [{ id: "codex", label: "Codex" }],
|
||||
default: "claude",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { AlertsNotifications } from "../AlertsNotifications";
|
||||
|
||||
describe("AlertsNotifications — spend rules", () => {
|
||||
it("previews and creates a budget rule", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertsNotifications />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await user.click(await screen.findByText("Add rule"));
|
||||
await user.type(screen.getByPlaceholderText("e.g. Error burst"), "Monthly");
|
||||
const selects = screen.getAllByRole("combobox");
|
||||
await user.selectOptions(selects[0]!, "budget");
|
||||
await user.selectOptions(screen.getByDisplayValue("month"), "week");
|
||||
const limit = screen.getByDisplayValue("100");
|
||||
await user.clear(limit);
|
||||
await user.type(limit, "50");
|
||||
await screen.findByRole("option", { name: "Codex" });
|
||||
await user.selectOptions(screen.getByDisplayValue("All AI tools"), "codex");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Preview/ }));
|
||||
const config = {
|
||||
period: "week",
|
||||
limit_usd: 50,
|
||||
thresholds: [80, 100],
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
tool: "codex",
|
||||
};
|
||||
expect(api.alerts.rules.preview).toHaveBeenCalledWith({
|
||||
rule_type: "budget",
|
||||
config,
|
||||
cooldown_seconds: 300,
|
||||
});
|
||||
expect(await screen.findByText("2026-09-01")).toBeInTheDocument();
|
||||
expect(screen.getByText("80% 100%")).toBeInTheDocument();
|
||||
expect(api.alerts.rules.create).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Create rule/ }));
|
||||
expect(api.alerts.rules.create).toHaveBeenCalledWith({
|
||||
name: "Monthly",
|
||||
rule_type: "budget",
|
||||
config,
|
||||
cooldown_seconds: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file EventReplay.test.tsx
|
||||
* @description The Replay tab's controls: step, jump, arrow keys, a lifecycle
|
||||
* marker jumping the cursor, playback advancing on a timer, and the derived
|
||||
* state following the cursor.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { EventReplay } from "../EventReplay";
|
||||
import type { DashboardEvent } from "../../lib/types";
|
||||
|
||||
function ev(id: number, event_type: string, tool_name: string | null = null): DashboardEvent {
|
||||
return {
|
||||
id,
|
||||
session_id: "s1",
|
||||
agent_id: "a1",
|
||||
event_type,
|
||||
tool_name,
|
||||
summary: `${event_type} ${tool_name ?? ""}`.trim(),
|
||||
data: null,
|
||||
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, id)).toISOString(),
|
||||
} as DashboardEvent;
|
||||
}
|
||||
|
||||
// Given newest first, as the events API answers: the replay orders them itself.
|
||||
const events = [
|
||||
ev(4, "Stop"),
|
||||
ev(3, "PostToolUse", "Bash"),
|
||||
ev(2, "PreToolUse", "Bash"),
|
||||
ev(1, "SessionStart"),
|
||||
];
|
||||
|
||||
function renderReplay() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<EventReplay events={events} agentInfoById={new Map()} sessionNameById={new Map()} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const position = () => screen.getByText(/^\d+ \/ 4/);
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("EventReplay", () => {
|
||||
it("starts at the first event and steps with the buttons", () => {
|
||||
renderReplay();
|
||||
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||||
fireEvent.click(screen.getByTitle("Step forward (→)"));
|
||||
fireEvent.click(screen.getByTitle("Step forward (→)"));
|
||||
expect(position().textContent).toMatch(/^3 \/ 4/);
|
||||
expect(screen.getByText("Bash ×1")).toBeTruthy();
|
||||
fireEvent.click(screen.getByTitle("Jump to end (End)"));
|
||||
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||||
fireEvent.click(screen.getByTitle("Jump to start (Home)"));
|
||||
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||||
});
|
||||
|
||||
it("moves on arrow keys and jumps from a lifecycle marker", () => {
|
||||
renderReplay();
|
||||
const root = screen.getByTestId("event-replay");
|
||||
fireEvent.keyDown(root, { key: "ArrowRight" });
|
||||
expect(position().textContent).toMatch(/^2 \/ 4/);
|
||||
// The in-flight tool at the PreToolUse.
|
||||
expect(screen.getByText("Running tool").nextSibling?.textContent).toBe("Bash");
|
||||
fireEvent.keyDown(root, { key: "End" });
|
||||
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Session start · #1" }));
|
||||
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||||
});
|
||||
|
||||
it("plays to the end at the chosen speed", () => {
|
||||
vi.useFakeTimers();
|
||||
renderReplay();
|
||||
fireEvent.click(screen.getByRole("button", { name: "5×" }));
|
||||
fireEvent.click(screen.getByTitle("Play (Space)"));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(position().textContent).toMatch(/^2 \/ 4/);
|
||||
// One step per tick: each step schedules the next.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
}
|
||||
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||||
expect(screen.getByTitle("Play (Space)")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description Responsive shell: with no stored preference the sidebar starts
|
||||
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
|
||||
* from a menu button — a modal dialog that Escape and the backdrop close,
|
||||
* handing focus back to the button.
|
||||
* handing focus back to the button. The expanded sidebar's edge resizes it
|
||||
* within its bounds and remembers the width.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -16,7 +17,7 @@ vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
|
||||
vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
|
||||
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
|
||||
|
||||
import { Layout, DRAWER_QUERY } from "../Layout";
|
||||
import { Layout, DRAWER_QUERY, SIDEBAR_MIN, SIDEBAR_MAX } from "../Layout";
|
||||
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
|
||||
|
||||
function viewport(width: number) {
|
||||
@@ -34,6 +35,7 @@ function viewport(width: number) {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
|
||||
localStorage.removeItem("agenthub.sidebar.width");
|
||||
});
|
||||
|
||||
describe("loadCollapsed", () => {
|
||||
@@ -95,3 +97,47 @@ describe("Layout drawer", () => {
|
||||
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layout sidebar width", () => {
|
||||
it("the edge drags within bounds, is remembered, and a double-click resets it", () => {
|
||||
viewport(1440);
|
||||
vi.stubGlobal("innerWidth", 2560);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Layout wsConnected />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const edge = screen.getByTestId("sidebar-resize");
|
||||
const main = screen.getByRole("main");
|
||||
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
|
||||
|
||||
fireEvent.pointerDown(edge, { pointerId: 1 });
|
||||
fireEvent.pointerMove(edge, { clientX: 360 });
|
||||
fireEvent.pointerUp(edge, { clientX: 360 });
|
||||
expect(main.style.marginLeft).toBe("360px");
|
||||
expect(screen.getByTestId("sidebar").style.width).toBe("360px");
|
||||
expect(localStorage.getItem("agenthub.sidebar.width")).toBe("360");
|
||||
|
||||
fireEvent.pointerDown(edge, { pointerId: 1 });
|
||||
fireEvent.pointerUp(edge, { clientX: 2000 });
|
||||
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX}px`);
|
||||
|
||||
fireEvent.keyDown(edge, { key: "ArrowLeft" });
|
||||
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX - 16}px`);
|
||||
|
||||
fireEvent.doubleClick(edge);
|
||||
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
|
||||
});
|
||||
|
||||
it("never wider than 40% of the window", () => {
|
||||
viewport(1100);
|
||||
vi.stubGlobal("innerWidth", 1100);
|
||||
localStorage.setItem("agenthub.sidebar.width", "480");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Layout wsConnected />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByRole("main").style.marginLeft).toBe("440px");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,21 @@ vi.mock("../../lib/api", () => ({
|
||||
daily_costs: [],
|
||||
})),
|
||||
},
|
||||
budgets: {
|
||||
status: vi.fn().mockResolvedValue({
|
||||
budgets: [
|
||||
{ id: "b1", name: "Quiet", config: { limit_usd: 100 }, spend: 10, pct: 10, crossed: [] },
|
||||
{
|
||||
id: "b2",
|
||||
name: "Monthly",
|
||||
config: { limit_usd: 100 },
|
||||
spend: 85,
|
||||
pct: 85,
|
||||
crossed: [80],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -142,6 +157,14 @@ describe("StatusBar", () => {
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
|
||||
});
|
||||
|
||||
it("flags the budget furthest past a threshold, linking to the cost analytics", async () => {
|
||||
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
const chip = await screen.findByTestId("status-bar-budget");
|
||||
expect(chip.textContent).toBe("Budget 85%");
|
||||
expect(chip.getAttribute("href")).toBe("/?tab=analytics&sub=cost");
|
||||
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||
|
||||
@@ -125,7 +125,23 @@ function HomeBody({
|
||||
const entry = aiTools.byId(tool);
|
||||
const claude = tool === "claude";
|
||||
const codex = tool === "codex";
|
||||
const [prompt, setPrompt] = useState("");
|
||||
// The half-typed prompt survives leaving the page or the workspace.
|
||||
const draftKey = `agenthub.workspace.draft.${lane.id}`;
|
||||
const [prompt, setPrompt] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(draftKey) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (prompt) localStorage.setItem(draftKey, prompt);
|
||||
else localStorage.removeItem(draftKey);
|
||||
} catch {
|
||||
/* storage unavailable: the draft just does not survive */
|
||||
}
|
||||
}, [draftKey, prompt]);
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const [router, setRouter] = useSessionRouter();
|
||||
const routers = useAiRouters();
|
||||
|
||||
@@ -193,7 +193,9 @@ describe("freeSlot / paneRunId", () => {
|
||||
it("picks the first slot no live run of this lane holds", () => {
|
||||
expect(freeSlot(1, [])).toBe(0);
|
||||
expect(freeSlot(1, runs)).toBe(1);
|
||||
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
|
||||
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(
|
||||
(slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle
|
||||
);
|
||||
expect(freeSlot(1, all)).toBeNull();
|
||||
expect(freeSlot(1, all.slice(0, 4))).toBe(4);
|
||||
expect(freeSlot(2, all)).toBe(0);
|
||||
@@ -222,6 +224,23 @@ describe("WorkspaceHome", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a half-typed prompt per workspace across a remount, and drops it once sent", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "long draft");
|
||||
first.unmount();
|
||||
const other = renderHome(lane({ id: 2 }));
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("");
|
||||
other.unmount();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("long draft");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
|
||||
});
|
||||
|
||||
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
|
||||
@@ -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