Merge origin/dev: لایهٔ realtime (WebSocket/RPC) + فلو دعوت عضو

ادغام ۱۲ کامیت ریموت (لایهٔ realtime روی WebSocket، حذف REST عمومی، رفع
باگ‌های صفحهٔ مدل‌ها) با ۸ کامیت محلی (ارائه‌دهندهٔ agy + رفع فلو دعوت).

تنها کانفلیکت واقعی در `app/main.py` بود و ماهوی بود، نه متنی:
ریموت اپ عمومی را به «فقط /health + /api/ws» کاهش داده و همهٔ هندلرهای HTTP
را به اپ داخلی `interfaces/realtime/rpc_app.py` منتقل کرده است. بنابراین
روتر `invites` من نباید در اپ عمومی mount شود.

راه‌حل:
- `app/main.py`: فقط `health` و `realtime_router` می‌مانند؛ ایمپورت‌های اضافی
  حذف شدند (نسخهٔ ریموت).
- `interfaces/realtime/rpc_app.py`: روتر `invites` به اپ داخلی اضافه شد تا
  `GET /api/invites/{token}` و `POST /api/invites/accept` از مسیر WS-RPC
  قابل دسترس باشند.

`api.ts` و `contracts/index.ts` خودکار و بدون کانفلیکت ادغام شدند: کلاینت
`previewInvite`/`acceptInvite` حالا از `getRealtimeClient().rpc()` رد می‌شود،
پس هیچ تغییری در فراخوانی‌ها لازم نبود.

رفع تست شکستهٔ ریموت: `src/test/version.test.tsx` مقدار `AppShell` را بدون
`RealtimeProvider` رندر می‌کرد و `useRealtime` استثنا می‌داد. حالا اسکلت
همان‌طور که در `app/layout.tsx` سیم‌کشی شده رندر می‌شود و سوکت در تست stub
شده است.

اعتبارسنجی روی درخت ادغام‌شده:
- بک‌اند ۲۷۰ تست سبز (شامل ۶ تست دعوت روی معماری جدید)؛ mypy تمیز.
- فرانت ۶۱ تست سبز؛ tsc و eslint تمیز.
- تأیید شد روترهای invite روی اپ داخلی RPC حاضرند و سطح عمومی تغییر نکرده.

نکته: ۵ خطای ruff در `tests/conftest.py`، `tests/test_error_log.py` و
`tests/test_realtime.py` از خود `origin/dev` می‌آیند و به این ادغام ربطی
ندارند؛ دست‌نخورده رها شدند.
This commit is contained in:
mjalmoudy94
2026-10-13 18:09:03 +03:30
49 changed files with 4370 additions and 294 deletions
@@ -25,6 +25,7 @@ import {
} from "@agy/ui";
import type { TableColumn } from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useLiveChannel } from "@/lib/realtime";
/**
* Platform-owner error console.
@@ -101,6 +102,63 @@ export default function AdminErrorsPage() {
const [busy, setBusy] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
// Live feed: new errors appear in the console as they happen.
useLiveChannel(["admin"], (event) => {
if (event.type === "error.recorded") {
const data = event.data as {
error_id: string | null;
severity: ErrorSeverity;
source: ErrorSource;
kind: string;
message: string;
organization_id: string | null;
};
const id = data.error_id ?? event.id;
setEvents((current) => {
if (current.some((e) => e.id === id)) return current;
return [
{
id,
kind: data.kind,
message: data.message,
stack: "",
severity: data.severity,
source: data.source,
error_type: data.kind,
fingerprint: id,
status_code: null,
method: "",
path: "",
organization_id: data.organization_id,
user_id: null,
run_id: null,
request_id: "",
ip: "",
user_agent: "",
metadata_json: {},
resolved_at: null,
resolved_note: "",
created_at: new Date().toISOString(),
},
...current,
];
});
setTotal((current) => current + 1);
setSummary((current) =>
current
? {
...current,
total_open: current.total_open + 1,
open_by_severity: {
...current.open_by_severity,
[data.severity]: (current.open_by_severity[data.severity] ?? 0) + 1,
},
}
: current,
);
}
});
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
+186 -25
View File
@@ -2,10 +2,10 @@
import * as React from "react";
import {
effortsForModel,
effortLabel,
type AdminModel,
type AgyAccount,
type AgyAccountModel,
type Capability,
type ReasoningEffort,
} from "@agy/contracts";
@@ -22,6 +22,65 @@ import {
LoadingState,
Select,
} from "@agy/ui";
/** Effort tokens an agy model id/name can encode, weakest -> strongest. */
const EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh"];
/** The single effort one agy catalog entry encodes, or ``null`` for a model
* that is not effort-specific (e.g. Claude "(Thinking)"). Detected from the
* id's dash-separated parts to avoid substring accidents ("xhigh" not "high"). */
function entryEffort(m: AgyAccountModel): string | null {
const parts = m.id.toLowerCase().split(/[-_]/);
for (const token of ["xhigh", "high", "medium", "low", "minimal"]) {
if (parts.includes(token)) return token;
}
const supported = m.supported_efforts ?? [];
return supported.length === 1 ? supported[0] : null;
}
/** Base label with any single-effort qualifier (" (High)") removed so effort
* variants of one family collapse into a single dropdown option. */
function baseModelName(m: AgyAccountModel): string {
const stripped = m.display_name
.replace(/\s*\((high|medium|low|minimal|xhigh)\)/i, "")
.trim();
return stripped || m.display_name;
}
/** A model family: N agy catalog entries that share a base name and differ only
* by reasoning effort (e.g. gemini-3.8-flash-high/-medium/-low). */
interface ModelFamily {
base: string;
entries: AgyAccountModel[];
efforts: string[];
supportsThinking: boolean;
supportsImages: boolean;
}
/** Group the live catalog into families for the model dropdown. */
function groupCatalog(catalog: AgyAccountModel[] | null): ModelFamily[] {
const groups = new Map<string, ModelFamily>();
for (const m of catalog ?? []) {
const base = baseModelName(m);
let g = groups.get(base);
if (!g) {
g = { base, entries: [], efforts: [], supportsThinking: false, supportsImages: false };
groups.set(base, g);
}
g.entries.push(m);
g.supportsThinking = g.supportsThinking || !!m.supports_thinking;
g.supportsImages = g.supportsImages || !!m.supports_images;
const own = entryEffort(m);
if (own && !g.efforts.includes(own)) g.efforts.push(own);
for (const e of m.supported_efforts ?? []) {
if (!g.efforts.includes(e)) g.efforts.push(e);
}
}
for (const g of groups.values()) {
g.efforts.sort((a, b) => EFFORT_ORDER.indexOf(a) - EFFORT_ORDER.indexOf(b));
}
return [...groups.values()];
}
import { ApiError, api } from "@/lib/api";
/**
@@ -29,8 +88,8 @@ import { ApiError, api } from "@/lib/api";
*
* agy is the only provider, and nothing about a model is typed by hand: the
* console asks the selected *account* for its live catalogue, the owner picks a
* model and a reasoning effort, and the rest is metadata. The model id itself
* never leaves the console — a customer only ever sees routing choices.
* model and a reasoning effort supported by that model, and the rest is metadata.
* The model id itself never leaves the console — a customer only ever sees routing choices.
*/
const CAPABILITIES: Capability[] = ["text", "vision", "tools", "reasoning", "long_context", "code"];
@@ -47,7 +106,7 @@ export default function AdminModelsPage() {
// The add-model cascade: provider -> account -> model -> effort.
const [providerId, setProviderId] = React.useState("agy");
const [accountId, setAccountId] = React.useState("");
const [catalog, setCatalog] = React.useState<string[] | null>(null);
const [catalog, setCatalog] = React.useState<AgyAccountModel[] | null>(null);
const [catalogLoading, setCatalogLoading] = React.useState(false);
const [catalogError, setCatalogError] = React.useState<string | null>(null);
const [modelId, setModelId] = React.useState("");
@@ -57,6 +116,13 @@ export default function AdminModelsPage() {
const [outputPrice, setOutputPrice] = React.useState("");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
/** The id of the model just added, briefly highlighted so the owner sees the
* list updated without hunting for it (the list sits below the form). */
const [justAddedId, setJustAddedId] = React.useState<string | null>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
/** Inline delete-confirmation: which row is armed, and which is in flight. */
const [confirmId, setConfirmId] = React.useState<string | null>(null);
const [deletingId, setDeletingId] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
@@ -114,6 +180,10 @@ export default function AdminModelsPage() {
}
}, []);
// The model dropdown shows one option per family (base name), not one per
// effort variant; the effort is chosen separately in the field below.
const groupedModels = React.useMemo(() => groupCatalog(catalog), [catalog]);
const onProviderChange = (value: string) => {
setProviderId(value);
// One provider exists, but the flow is wired for real: picking it reloads
@@ -135,14 +205,22 @@ export default function AdminModelsPage() {
const onModelChange = (value: string) => {
setModelId(value);
setEffort("");
// Sensible defaults the owner can still adjust: everything answers text,
// and a thinking-capable model is a reasoning model.
setCaps(
effortsForModel(value).length > 0
? ["text", "reasoning"]
: ["text"],
);
const family = groupedModels.find((g) => g.base === value);
const supported = family ? family.efforts : [];
// Default to the strongest effort the family offers (matches agy's default);
// a family with no efforts (e.g. Claude) leaves the field at its placeholder.
setEffort(supported.includes("high") ? "high" : (supported[0] ?? ""));
// Auto-detect capabilities from the family metadata.
const nextCaps: Capability[] = ["text"];
if (family?.supportsThinking || supported.length > 0) {
nextCaps.push("reasoning");
}
if (family?.supportsImages) {
nextCaps.push("vision");
}
setCaps(nextCaps);
};
const toggleCap = (cap: Capability) => {
@@ -162,16 +240,29 @@ export default function AdminModelsPage() {
setFormError("حداقل یک توانایی انتخاب کنید.");
return;
}
const family = groupedModels.find((g) => g.base === modelId);
// Resolve the family + chosen effort back to the concrete agy catalog entry
// (e.g. "Gemini 3.8 Flash" + medium -> gemini-3.8-flash-medium), so the saved
// internal_name and reasoning_effort are exactly what a direct pick produced.
const resolved = family
? (family.entries.find((e) => entryEffort(e) === effort) ??
family.entries.find((e) => entryEffort(e) === "high") ??
family.entries[0])
: undefined;
setCreating(true);
try {
const created = await api.adminCreateModel({
internal_name: modelId,
internal_name: resolved?.id ?? modelId,
display_name_for_admin: resolved?.display_name || modelId,
capabilities: caps,
input_price_per_1m: Number(inputPrice) || 0,
output_price_per_1m: Number(outputPrice) || 0,
reasoning_effort: effort || undefined,
});
setModels((current) => [created, ...current]);
setJustAddedId(created.id);
window.setTimeout(() => setJustAddedId(null), 2500);
listRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
setModelId("");
setEffort("");
setInputPrice("");
@@ -184,13 +275,32 @@ export default function AdminModelsPage() {
}
};
const efforts: ReasoningEffort[] = modelId ? effortsForModel(modelId) : [];
const onDelete = async (model: AdminModel) => {
setDeletingId(model.id);
try {
await api.adminDeleteModel(model.id);
setModels((current) => current.filter((m) => m.id !== model.id));
setConfirmId(null);
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "حذف مدل ناموفق بود.");
} finally {
setDeletingId(null);
}
};
const selectedFamily = groupedModels.find((g) => g.base === modelId);
const efforts: ReasoningEffort[] = selectedFamily
? (selectedFamily.efforts as ReasoningEffort[])
: [];
const accountOptions = accounts.map((a) => ({
value: a.id,
label: a.email || a.name || a.id,
}));
const modelOptions = (catalog ?? []).map((id) => ({ value: id, label: id }));
const effortOptions = efforts.map((e) => ({ value: e, label: effortLabel(e) }));
const modelOptions = groupedModels.map((g) => ({ value: g.base, label: g.base }));
const effortOptions = [
{ value: "", label: "پیش‌فرض سامانه" },
...efforts.map((e) => ({ value: e, label: effortLabel(e) })),
];
return (
<div className="anim-stagger flex flex-col gap-5">
@@ -226,7 +336,7 @@ export default function AdminModelsPage() {
options={
accountOptions.length === 0
? [{ value: "", label: "اکانتی ثبت نشده — ابتدا اکانت agy بسازید" }]
: accountOptions
: [{ value: "", label: "اکانت agy را انتخاب کنید" }, ...accountOptions]
}
hint="کاتالوگ مدل‌ها از همین اکانت خوانده می‌شود."
/>
@@ -241,9 +351,11 @@ export default function AdminModelsPage() {
options={
!accountId
? [{ value: "", label: "ابتدا یک اکانت انتخاب کنید" }]
: modelOptions.length === 0
? [{ value: "", label: "کاتالوگی برای این اکانت نیست" }]
: modelOptions
: catalogLoading
? [{ value: "", label: "در حال بارگذاری کاتالوگ…" }]
: modelOptions.length === 0
? [{ value: "", label: "کاتالوگی برای این اکانت نیست" }]
: [{ value: "", label: "یک مدل انتخاب کنید" }, ...modelOptions]
}
disabled={!accountId || catalogLoading || modelOptions.length === 0}
/>
@@ -251,12 +363,18 @@ export default function AdminModelsPage() {
label="افورت استدلال"
value={effort}
onChange={(e) => setEffort(e.target.value)}
options={effortOptions}
disabled={efforts.length === 0}
options={
!modelId
? [{ value: "", label: "ابتدا یک مدل انتخاب کنید" }]
: efforts.length === 0
? [{ value: "", label: "این مدل از افورت استدلال پشتیبانی نمی‌کند" }]
: effortOptions
}
disabled={!modelId || efforts.length === 0}
hint={
efforts.length === 0
? "این مدل افورت استدلال ندارد."
: "بودجهٔ تفکر مدل؛ روی مدل‌های غیراستدلالی نادیده گرفته می‌شود."
? "این مدل افورت استدلال ندارد (مدل غیراستدلالی یا با استدلال ثابت)."
: "افورت‌های استدلال پشتیبانی‌شدهٔ همین مدل در agy."
}
/>
</div>
@@ -328,6 +446,7 @@ export default function AdminModelsPage() {
description="بدون مدل فعال، هیچ حالت مسیریابی قابل اجرا نیست."
/>
) : (
<div ref={listRef}>
<Card>
<CardTitle>فهرست مدل‌ها</CardTitle>
<div className="mt-3 overflow-x-auto">
@@ -340,11 +459,18 @@ export default function AdminModelsPage() {
<th className="px-2 py-2 text-right font-medium">ورودی</th>
<th className="px-2 py-2 text-right font-medium">خروجی</th>
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
<th className="px-2 py-2 text-right font-medium">عملیات</th>
</tr>
</thead>
<tbody className="anim-stagger">
{models.map((model) => (
<tr key={model.id} className="border-b border-surface-border/60">
<tr
key={model.id}
className={
"border-b border-surface-border/60" +
(model.id === justAddedId ? " bg-emerald-500/10" : "")
}
>
<td className="px-2 py-2">
<span className="ltr-inline text-slate-200">{model.internal_name}</span>
<p className="text-xs text-slate-500">
@@ -378,12 +504,47 @@ export default function AdminModelsPage() {
{model.is_active ? "فعال" : "غیرفعال"}
</Badge>
</td>
<td className="px-2 py-2">
{confirmId === model.id ? (
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="danger"
loading={deletingId === model.id}
onClick={() => void onDelete(model)}
>
تأیید حذف
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={deletingId === model.id}
onClick={() => setConfirmId(null)}
>
انصراف
</Button>
</div>
) : (
<Button
type="button"
size="sm"
variant="ghost"
className="text-danger hover:bg-danger/10"
onClick={() => setConfirmId(model.id)}
>
حذف
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</div>
);
+39
View File
@@ -23,6 +23,7 @@ import {
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_MANAGE_BILLING, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
const UNIT_LABELS: Record<string, string> = {
@@ -69,6 +70,44 @@ export default function BillingPage() {
const [actionError, setActionError] = React.useState<string | null>(null);
const [checkoutUrl, setCheckoutUrl] = React.useState<string | null>(null);
// Live feed: wallet balance and usage events update as they happen.
useLiveChannel(
orgId ? [`org:${orgId}`] : [],
(event) => {
switch (event.type) {
case "billing.wallet": {
const data = event.data as { organization_id: string; balance: number };
setSummary((current) =>
current ? { ...current, balance: data.balance, low_balance: Boolean(data.balance <= current.low_balance_threshold) } : current,
);
break;
}
case "usage.recorded": {
const data = event.data as {
run_id: string | null;
unit: string;
quantity: number;
};
setEvents((current) => [
{
id: event.id,
run_id: data.run_id,
unit: data.unit as UsageEvent["unit"],
quantity: data.quantity,
unit_price: 0,
total_price: 0,
created_at: new Date().toISOString(),
},
...current,
]);
break;
}
default:
break;
}
},
);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
+27
View File
@@ -15,6 +15,7 @@ import {
UsageBar,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
export default function DashboardPage() {
@@ -24,6 +25,32 @@ export default function DashboardPage() {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
// Live feed: audit events stream into the timeline; usage updates the bar.
useLiveChannel(
activeOrganization ? [`org:${activeOrganization.id}`] : [],
(event) => {
switch (event.type) {
case "audit.recorded": {
const data = event.data as { action: string; target_type: string; target_id: string };
setAudit((current) => [
{
id: event.id,
action: data.action,
actor_user_id: null,
target_type: data.target_type,
target_id: data.target_id,
created_at: new Date().toISOString(),
},
...current,
]);
break;
}
default:
break;
}
},
);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
+59 -1
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import * as React from "react";
import type { Employee, Message, Task, Thread } from "@agy/contracts";
import type { Employee, Message, Task, TaskStatus, Thread } from "@agy/contracts";
import {
Badge,
Button,
@@ -17,6 +17,7 @@ import {
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
/**
@@ -48,6 +49,63 @@ export default function ManagerPage() {
const bottomRef = React.useRef<HTMLDivElement | null>(null);
// Live feed: new messages in the active thread, and tasks created from chat.
const liveChannels = React.useMemo(() => {
const base: string[] = [];
if (orgId) base.push(`org:${orgId}`);
if (activeThreadId) base.push(`thread:${activeThreadId}`);
return base;
}, [orgId, activeThreadId]);
useLiveChannel(liveChannels, (event) => {
switch (event.type) {
case "message.created": {
const data = event.data as {
thread_id: string;
message: { id: string; role: string; content: string; employee_id?: string | null };
};
if (data.thread_id === activeThreadId) {
setMessages((current) => [
...current,
{
id: data.message.id,
thread_id: data.thread_id,
role: data.message.role as Message["role"],
content: data.message.content,
employee_id: data.message.employee_id ?? null,
created_at: new Date().toISOString(),
},
]);
}
break;
}
case "task.created": {
const data = event.data as { task_id: string; title: string; status: string };
setTasks((current) => {
if (current.some((t) => t.id === data.task_id)) return current;
return [
{
id: data.task_id,
organization_id: orgId ?? "",
project_id: null,
thread_id: activeThreadId,
title: data.title,
description: "",
status: data.status as TaskStatus,
assigned_employee_id: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
...current,
];
});
break;
}
default:
break;
}
});
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
+108 -1
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import * as React from "react";
import type { Employee, RunDetail, TaskDetail, TaskStatus, ToolCall } from "@agy/contracts";
import type { Employee, RunDetail, RunStatus, TaskDetail, TaskStatus, ToolCall } from "@agy/contracts";
import {
Badge,
Button,
@@ -17,6 +17,7 @@ import {
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
const STATUS_LABELS: Record<TaskStatus, string> = {
@@ -64,6 +65,112 @@ export default function TaskDetailPage({
const [running, setRunning] = React.useState(false);
const [actionError, setActionError] = React.useState<string | null>(null);
// Live feed: task status, run steps, approvals, artifacts — all push.
const channels = React.useMemo(() => {
const base: string[] = [];
if (orgId) base.push(`org:${orgId}`);
if (taskId) base.push(`task:${taskId}`);
if (selectedRun?.id) base.push(`run:${selectedRun.id}`);
return base;
}, [orgId, taskId, selectedRun?.id]);
useLiveChannel(channels, (event) => {
switch (event.type) {
case "task.status": {
const data = event.data as { task_id: string; status: string };
if (data.task_id === taskId) {
setTask((current) => (current ? { ...current, status: data.status as TaskStatus } : current));
}
break;
}
case "run.step": {
const data = event.data as {
run_id: string;
task_id: string;
step: { id: string; index: number; kind: string; title: string; payload: Record<string, unknown> };
};
setSelectedRun((current) => {
if (!current || current.id !== data.run_id) return current;
return {
...current,
steps: [
...current.steps,
{
id: data.step.id,
index: data.step.index,
kind: data.step.kind,
title: data.step.title,
payload_json: data.step.payload,
created_at: new Date().toISOString(),
},
],
};
});
break;
}
case "run.status": {
const data = event.data as { run_id: string; task_id: string; status: string };
setSelectedRun((current) =>
current && current.id === data.run_id
? { ...current, status: data.status as RunDetail["status"] }
: current,
);
setTask((current) => {
if (!current) return current;
return {
...current,
runs: current.runs.map((r) =>
r.id === data.run_id ? { ...r, status: data.status as RunStatus } : r,
),
};
});
break;
}
case "approval.pending": {
const data = event.data as { tool_call_id: string; tool_key: string; run_id: string };
setApprovals((current) => {
if (current.some((a) => a.id === data.tool_call_id)) return current;
return [
...current,
{
id: data.tool_call_id,
tool_key: data.tool_key,
status: "awaiting_approval",
requires_approval: true,
arguments_json: {},
result_json: {},
created_at: new Date().toISOString(),
},
];
});
break;
}
case "artifact.created": {
const data = event.data as { artifact_id: string; run_id: string; name: string };
setSelectedRun((current) => {
if (!current || current.id !== data.run_id) return current;
return {
...current,
artifacts: [
...current.artifacts,
{
id: data.artifact_id,
name: data.name,
kind: "text",
size_bytes: 0,
content_ref: "",
created_at: new Date().toISOString(),
},
],
};
});
break;
}
default:
break;
}
});
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
+67 -1
View File
@@ -18,7 +18,7 @@
import Link from "next/link";
import * as React from "react";
import type { AuditEvent, Run, RunDetail } from "@agy/contracts";
import type { AuditEvent, Run, RunDetail, RunStatus } from "@agy/contracts";
import {
Badge,
Button,
@@ -32,6 +32,7 @@ import {
Timeline,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
/** How many tasks to fan out into. Bounds the request count. */
@@ -108,6 +109,71 @@ export default function TracesPage() {
const [error, setError] = React.useState<string | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
// Live feed: run steps and status changes update both the list and the detail.
useLiveChannel(
orgId ? [`org:${orgId}`] : [],
(event) => {
switch (event.type) {
case "run.step": {
const data = event.data as {
run_id: string;
task_id: string;
step: { id: string; index: number; kind: string; title: string; payload: Record<string, unknown> };
};
setSelected((current) => {
if (!current || current.id !== data.run_id) return current;
return {
...current,
steps: [
...current.steps,
{
id: data.step.id,
index: data.step.index,
kind: data.step.kind,
title: data.step.title,
payload_json: data.step.payload,
created_at: new Date().toISOString(),
},
],
};
});
break;
}
case "run.status": {
const data = event.data as { run_id: string; task_id: string; status: string };
setRows((current) =>
current.map((row) =>
row.run.id === data.run_id ? { ...row, run: { ...row.run, status: data.status as RunStatus } } : row,
),
);
setSelected((current) =>
current && current.id === data.run_id
? { ...current, status: data.status as RunDetail["status"] }
: current,
);
break;
}
case "audit.recorded": {
const data = event.data as { action: string; target_type: string; target_id: string };
setAudit((current) => [
{
id: event.id,
action: data.action,
actor_user_id: null,
target_type: data.target_type,
target_id: data.target_id,
created_at: new Date().toISOString(),
},
...current,
]);
break;
}
default:
break;
}
},
);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" fill="none">
<rect width="32" height="32" rx="8" fill="#0b0d12"/>
<rect x="0.5" y="0.5" width="31" height="31" rx="7.5" stroke="#1e293b" stroke-width="1"/>
<defs>
<linearGradient id="brandGrad" x1="6" y1="6" x2="26" y2="26" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#38bdf8"/>
<stop offset="50%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#a855f7"/>
</linearGradient>
</defs>
<circle cx="16" cy="10" r="2.5" fill="url(#brandGrad)"/>
<circle cx="9" cy="22" r="2.5" fill="url(#brandGrad)"/>
<circle cx="23" cy="22" r="2.5" fill="url(#brandGrad)"/>
<path d="M16 10L9 22M16 10L23 22M9 22H23" stroke="url(#brandGrad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16" cy="18" r="1.5" fill="#38bdf8"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

+5 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { RealtimeProvider } from "@/lib/realtime";
import { SessionProvider } from "@/lib/session";
export const metadata: Metadata = {
@@ -11,7 +12,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="fa" dir="rtl" className="dark">
<body>
<SessionProvider>{children}</SessionProvider>
<SessionProvider>
{/* One socket for the whole app: every live feed rides on it. */}
<RealtimeProvider>{children}</RealtimeProvider>
</SessionProvider>
</body>
</html>
);