Files
smart-org/apps/web/src/app/(app)/traces/page.tsx
T
mjalmoudy94 54074f10c3 اضافه کردن transition-colors به همهٔ حالت‌های hover
چندین سطح تعاملی (منوی سازمان، منوی کاربر، لینک‌های بردکرامب، آیتم‌های
پالت فرمان، دکمهٔ ساخت سازمان، لینک ردیابی) رنگ را در hover یک‌باره
تغییر می‌دادند و بقیهٔ رابط نرم بود. اکنون همه یکسان transition
می‌گیرند.
2026-10-13 02:55:49 +03:30

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>
);
}