چندین سطح تعاملی (منوی سازمان، منوی کاربر، لینکهای بردکرامب، آیتمهای پالت فرمان، دکمهٔ ساخت سازمان، لینک ردیابی) رنگ را در hover یکباره تغییر میدادند و بقیهٔ رابط نرم بود. اکنون همه یکسان transition میگیرند.
372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Cross-cutting trace view.
|
|
*
|
|
* One place to reconstruct what the platform did: every run across the
|
|
* organization, its step timeline, the tools it called, and the audit stream
|
|
* beside it.
|
|
*
|
|
* Two deliberate constraints:
|
|
*
|
|
* - The task list is capped before fanning out. There is no "list all runs for
|
|
* an organization" endpoint, so we read runs through their owning task; an
|
|
* uncapped fan-out would turn one page load into hundreds of requests.
|
|
* - No model or provider identity is rendered. Those never leave the platform
|
|
* owner's console.
|
|
*/
|
|
|
|
import Link from "next/link";
|
|
import * as React from "react";
|
|
import type { AuditEvent, Run, RunDetail } from "@agy/contracts";
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
CardHint,
|
|
CardTitle,
|
|
EmptyState,
|
|
ErrorState,
|
|
LoadingState,
|
|
Stat,
|
|
Timeline,
|
|
} from "@agy/ui";
|
|
import { ApiError, api } from "@/lib/api";
|
|
import { useSession } from "@/lib/session";
|
|
|
|
/** How many tasks to fan out into. Bounds the request count. */
|
|
const TASK_SCAN_LIMIT = 12;
|
|
|
|
const RUN_TONES: Record<string, "neutral" | "info" | "success" | "warning" | "danger"> = {
|
|
queued: "neutral",
|
|
planning: "info",
|
|
running: "info",
|
|
tool_calling: "info",
|
|
sandbox_running: "info",
|
|
waiting_approval: "warning",
|
|
completed: "success",
|
|
failed: "danger",
|
|
cancelled: "neutral",
|
|
timed_out: "danger",
|
|
};
|
|
|
|
const AUDIT_LABELS: Record<string, string> = {
|
|
"auth.login": "ورود کاربر",
|
|
"auth.signup": "ثبتنام کاربر",
|
|
"org.create": "ساخت سازمان",
|
|
"org.member_invite": "دعوت عضو",
|
|
"org.member_update": "تغییر نقش عضو",
|
|
"org.member_remove": "حذف عضو",
|
|
"project.create": "ساخت پروژه",
|
|
"project.repo_ensure": "ساخت مخزن",
|
|
"project.preview_domain_create": "ساخت دامنهٔ تست",
|
|
"project.preview_domain_delete": "حذف دامنهٔ تست",
|
|
"employee.create": "ساخت کارمند",
|
|
"employee.update": "ویرایش کارمند",
|
|
"employee.prompt_version": "نسخهٔ پرامپت جدید",
|
|
"employee.tool_permission": "تغییر دسترسی ابزار",
|
|
"task.create": "ساخت کار",
|
|
"run.start": "شروع اجرا",
|
|
"run.cancel": "لغو اجرا",
|
|
"approval.decide": "تصمیم تأیید",
|
|
"billing.add_credit": "افزایش اعتبار",
|
|
"billing.checkout": "شروع پرداخت",
|
|
"billing.settle": "تسویهٔ پرداخت",
|
|
"sandbox.workspace_create": "ساخت محیط ایزوله",
|
|
"sandbox.destroy": "حذف محیط ایزوله",
|
|
"sandbox.command": "اجرای دستور در sandbox",
|
|
};
|
|
|
|
function auditLabel(action: string): string {
|
|
return AUDIT_LABELS[action] ?? action;
|
|
}
|
|
|
|
function formatDuration(run: Run): string {
|
|
if (!run.started_at) return "—";
|
|
const end = run.finished_at ? new Date(run.finished_at).getTime() : Date.now();
|
|
const ms = end - new Date(run.started_at).getTime();
|
|
if (ms < 1000) return `${ms}ms`;
|
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
return `${Math.round(ms / 60_000)}m`;
|
|
}
|
|
|
|
interface RunRow {
|
|
run: Run;
|
|
taskId: string;
|
|
taskTitle: string;
|
|
}
|
|
|
|
export default function TracesPage() {
|
|
const { activeOrganization } = useSession();
|
|
const orgId = activeOrganization?.id ?? null;
|
|
|
|
const [rows, setRows] = React.useState<RunRow[]>([]);
|
|
const [audit, setAudit] = React.useState<AuditEvent[]>([]);
|
|
const [selected, setSelected] = React.useState<RunDetail | null>(null);
|
|
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [detailLoading, setDetailLoading] = React.useState(false);
|
|
|
|
const load = React.useCallback(async () => {
|
|
if (!orgId) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const [tasks, auditEvents] = await Promise.all([
|
|
api.listTasks(orgId),
|
|
api.listAudit(orgId),
|
|
]);
|
|
setAudit(auditEvents);
|
|
|
|
const recent = tasks.slice(0, TASK_SCAN_LIMIT);
|
|
const details = await Promise.all(
|
|
recent.map(async (task) => {
|
|
try {
|
|
return { task, detail: await api.getTask(orgId, task.id) };
|
|
} catch {
|
|
// One unreadable task must not blank the whole trace view.
|
|
return { task, detail: null };
|
|
}
|
|
}),
|
|
);
|
|
|
|
const collected: RunRow[] = [];
|
|
for (const { task, detail } of details) {
|
|
for (const run of detail?.runs ?? []) {
|
|
collected.push({ run, taskId: task.id, taskTitle: task.title });
|
|
}
|
|
}
|
|
collected.sort(
|
|
(a, b) => new Date(b.run.created_at).getTime() - new Date(a.run.created_at).getTime(),
|
|
);
|
|
setRows(collected);
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "دریافت ردیابی ناموفق بود.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [orgId]);
|
|
|
|
React.useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const onSelect = async (runId: string) => {
|
|
if (!orgId) return;
|
|
setSelectedId(runId);
|
|
setDetailLoading(true);
|
|
try {
|
|
setSelected(await api.getRun(orgId, runId));
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "دریافت جزئیات اجرا ناموفق بود.");
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
};
|
|
|
|
if (!activeOrganization) {
|
|
return (
|
|
<EmptyState
|
|
title="سازمانی انتخاب نشده است"
|
|
description="برای دیدن ردیابی ابتدا یک سازمان بسازید."
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (loading) return <LoadingState label="در حال جمعآوری ردیابی…" />;
|
|
if (error) return <ErrorState message={error} onRetry={load} />;
|
|
|
|
const failed = rows.filter((r) => r.run.status === "failed" || r.run.status === "timed_out").length;
|
|
|
|
return (
|
|
<div className="anim-stagger flex flex-col gap-5">
|
|
<header className="flex flex-col gap-1">
|
|
<h1 className="text-lg font-medium text-slate-100">ردیابی و رویدادها</h1>
|
|
<p className="text-sm text-slate-400">
|
|
هر اجرا با خط زمانی گامها، ابزارهای فراخوانیشده و رویدادهای audit. هویت مدل و
|
|
ارائهدهنده در این نما نمایش داده نمیشود.
|
|
</p>
|
|
</header>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<Stat label="اجراها" value={rows.length} hint={`از ${TASK_SCAN_LIMIT} کار آخر`} />
|
|
<Stat label="اجراهای ناموفق" value={failed} tone={failed > 0 ? "danger" : "neutral"} />
|
|
<Stat label="رویدادهای audit" value={audit.length} />
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-[1fr_22rem]">
|
|
<div className="flex flex-col gap-4">
|
|
<Card>
|
|
<CardTitle>اجراها</CardTitle>
|
|
<CardHint>جدیدترین اجراها در همهٔ کارها.</CardHint>
|
|
{rows.length === 0 ? (
|
|
<p className="mt-3 text-xs text-slate-500">هنوز اجرایی ثبت نشده است.</p>
|
|
) : (
|
|
<ul className="anim-stagger mt-3 flex flex-col gap-2">
|
|
{rows.map(({ run, taskId, taskTitle }) => (
|
|
<li key={run.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => void onSelect(run.id)}
|
|
className={
|
|
"flex w-full flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2 text-right transition-colors " +
|
|
(selectedId === run.id
|
|
? "border-accent/60 bg-surface-overlay"
|
|
: "border-surface-border hover:bg-surface-overlay")
|
|
}
|
|
>
|
|
<div className="flex min-w-0 flex-col gap-0.5">
|
|
<Link
|
|
href={`/tasks/${taskId}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="transition-colors truncate text-xs text-slate-200 hover:text-accent"
|
|
>
|
|
{taskTitle}
|
|
</Link>
|
|
<span className="ltr-inline text-xs text-slate-500">
|
|
{run.id.slice(0, 8)} · {new Date(run.created_at).toLocaleString("fa-IR")}
|
|
</span>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<span className="text-xs text-slate-500">{formatDuration(run)}</span>
|
|
<Badge tone={RUN_TONES[run.status] ?? "neutral"}>{run.status}</Badge>
|
|
</div>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Card>
|
|
|
|
{detailLoading && <LoadingState label="در حال دریافت جزئیات اجرا…" />}
|
|
|
|
{selected && !detailLoading && (
|
|
<Card>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle>خط زمانی اجرا</CardTitle>
|
|
<CardHint>
|
|
<span className="ltr-inline">{selected.id}</span>
|
|
</CardHint>
|
|
</div>
|
|
<Badge tone={RUN_TONES[selected.status] ?? "neutral"}>{selected.status}</Badge>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
{selected.steps.length === 0 ? (
|
|
<p className="text-xs text-slate-500">گامی ثبت نشده است.</p>
|
|
) : (
|
|
<Timeline
|
|
items={selected.steps.map((step) => ({
|
|
id: step.id,
|
|
title: step.title || step.kind,
|
|
meta: new Date(step.created_at).toLocaleString("fa-IR"),
|
|
tone:
|
|
step.kind === "error"
|
|
? ("danger" as const)
|
|
: step.kind === "artifact"
|
|
? ("success" as const)
|
|
: ("info" as const),
|
|
}))}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{selected.error && (
|
|
<div className="mt-3">
|
|
<ErrorState title="خطای اجرا" message={selected.error} />
|
|
</div>
|
|
)}
|
|
|
|
{selected.tool_calls.length > 0 && (
|
|
<div className="mt-4">
|
|
<p className="mb-2 text-xs font-medium text-slate-300">ابزارها</p>
|
|
<ul className="flex flex-col gap-2">
|
|
{selected.tool_calls.map((call) => (
|
|
<li
|
|
key={call.id}
|
|
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
|
|
>
|
|
<span className="ltr-inline text-xs text-slate-300">{call.tool_key}</span>
|
|
<div className="flex gap-2">
|
|
{call.requires_approval && <Badge tone="warning">نیازمند تأیید</Badge>}
|
|
<Badge
|
|
tone={
|
|
call.status === "succeeded"
|
|
? "success"
|
|
: call.status === "failed" || call.status === "rejected"
|
|
? "danger"
|
|
: "neutral"
|
|
}
|
|
>
|
|
{call.status}
|
|
</Badge>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{selected.artifacts.length > 0 && (
|
|
<div className="mt-4">
|
|
<p className="mb-2 text-xs font-medium text-slate-300">خروجیها</p>
|
|
<ul className="flex flex-col gap-1.5">
|
|
{selected.artifacts.map((artifact) => (
|
|
<li key={artifact.id} className="text-xs text-slate-400">
|
|
<span className="text-slate-200">{artifact.name}</span> ·{" "}
|
|
<span className="ltr-inline">{artifact.kind}</span> ·{" "}
|
|
{artifact.size_bytes} بایت
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
{!selected && !detailLoading && rows.length > 0 && (
|
|
<EmptyState
|
|
title="یک اجرا را انتخاب کنید"
|
|
description="برای دیدن گامها، ابزارها و خروجیها یکی از اجراهای بالا را بزنید."
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<Card className="h-fit">
|
|
<CardTitle>رویدادهای audit</CardTitle>
|
|
<CardHint>هر اقدام حساس با کنشگر و هدف ثبت میشود.</CardHint>
|
|
<div className="mt-4 max-h-[32rem] overflow-auto">
|
|
{audit.length === 0 ? (
|
|
<p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>
|
|
) : (
|
|
<Timeline
|
|
items={audit.slice(0, 60).map((event) => ({
|
|
id: event.id,
|
|
title: auditLabel(event.action),
|
|
meta: `${new Date(event.created_at).toLocaleString("fa-IR")} · ${
|
|
event.target_type
|
|
}`,
|
|
tone: event.action.includes("remove") || event.action.includes("cancel")
|
|
? ("danger" as const)
|
|
: ("info" as const),
|
|
}))}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button variant="secondary" size="sm" onClick={load}>
|
|
بازخوانی
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|