نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
"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="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="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="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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user