4aae61297f
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.
171 lines
6.6 KiB
TypeScript
171 lines
6.6 KiB
TypeScript
/**
|
|
* @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>
|
|
);
|
|
}
|